// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import Charts
import Foundation
import SwiftUI
/// `PreferenceKey` used to retrieve the width of a view during the layout process.
struct TooltipViewWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
/// Represents a view displaying a tooltip with the date and corresponding price.
struct TooltipView: View {
/// Properties for the content and position of the tooltip.
var currency: String
var price: Double
var date: Date
var xPosition: CGFloat
var chartWidth: CGFloat
/// Corner radius.
static let cornerRadius = 44.0
/// Vertical padding.
static let verticalPadding = 8.0
/// Horizontal padding.
static let horizontalPadding = 2.0
/// Size of the text.
static let textSize = 11.0
/// Color for the tool tip background.
static let tooltipBackgroundColor = "tooltip_background_color"
/// Color for the tool tip text.
static let tooltipTextColor = "tooltip_text_color"
/// Tooltip width value.
@State private var tooltipWidth: CGFloat = 0
/// layoutDirection environment value.
@Environment(\.layoutDirection) var layoutDirection
var body: some View {
var tooltipText: String {
let priceFormatted =
price.formatted(.currency(code: currency).precision(.fractionLength(0)))
let dateFormatted = date.formatted(date: .abbreviated, time: .omitted)
return layoutDirection == .leftToRight
? "\(priceFormatted) \(dateFormatted)" : "\(dateFormatted) \(priceFormatted)"
}
Text(tooltipText)
.font(.system(size: Self.textSize))
.foregroundColor(Color(Self.tooltipTextColor))
.padding([.leading, .trailing], Self.verticalPadding)
.padding([.bottom, .top], Self.horizontalPadding)
.background(
GeometryReader { geo in
RoundedRectangle(cornerRadius: Self.cornerRadius)
.fill(Color(Self.tooltipBackgroundColor))
.preference(key: TooltipViewWidthKey.self, value: geo.size.width)
}
)
.onPreferenceChange(TooltipViewWidthKey.self) { newWidth in
tooltipWidth = newWidth
}
.position(x: xPosition, y: 0.0)
.offset(
x: {
/// Adjusts the horizontal position of the tooltip to ensure it stays
/// within the chart's bounds and doesn't overflow out of bounds of the chart.
if xPosition < tooltipWidth / 2 {
/// If the tooltip is too far to the left, shift it right
return max(0, abs(xPosition - tooltipWidth / 2))
}
if xPosition > (chartWidth - (tooltipWidth / 2)) {
/// If the tooltip is too far to the right, shift it left
return min(0, chartWidth - (tooltipWidth / 2) - xPosition)
}
return 0.0
}(), y: 0.0)
}
}
/// Represents a view displaying a historical graph.
struct HistoryGraph: View {
/// The price history data consisting of dates and corresponding prices.
let history: [Date: NSNumber]
let currency: String
/// Graph gradient color.
static let graphGradientColor = "graph_gradient_color"
/// Color representing blue (600).
static let blue600 = UIColor(named: kBlue600Color) ?? .blue
/// Color representing solid white.
static let backgroundColor = UIColor(named: kBackgroundColor) ?? .white
/// Color representing grey 200.
static let grey200 = UIColor(named: kGrey200Color) ?? .gray
/// Number of ticks on the Y-axis.
static let tickCountY = 3
/// The selected date on the graph.
@State private var selectedDate: Date?
/// The horizontal x position of the current selection on the chart.
@State private var selectedXPosition: CGFloat?
/// The width of the entire chart.
@State private var chartWidth: CGFloat?
/// Color scheme environment value .
@Environment(\.colorScheme) var colorScheme
var body: some View {
/// A linear gradient used for styling the graph.
let linearGradient = LinearGradient(
gradient: Gradient(colors: [
Color(Self.graphGradientColor).opacity(colorScheme == .dark ? 0.2 : 0.4),
Color.clear,
]),
startPoint: .top,
endPoint: .bottom)
/// Sort price history data to render them in graph.
let sortedHistoryDates = Array(history.sorted(by: { $0.key < $1.key }))
let sortedHistoryPrice = Array(
history.values.sorted { $0.doubleValue < $1.doubleValue })
/// Calculating axis ticks and range.
let (axisTicksY, axisYRange) = getYAxisTicksInfo(prices: sortedHistoryPrice)
let axisXRange =
(sortedHistoryDates.first?.key ?? Date())...(sortedHistoryDates.last?.key ?? Date())
/// TODO(b/333894542): Configure audio graph for accessibility and ensure labels
/// for line marks and rule marks are accessible.
Chart {
ForEach(sortedHistoryDates, id: \.key) { date, price in
/// Displaying the area mark under the line mark.
AreaMark(
x: .value("Date", date),
yStart: .value("Minimun price in range", axisTicksY.first ?? 0),
yEnd: .value("Price", price.doubleValue)
)
.foregroundStyle(linearGradient)
// Displaying the line mark on the graph.
LineMark(
x: .value("Date", date),
y: .value("Price", price.doubleValue)
).foregroundStyle(Color(uiColor: Self.blue600))
}
.interpolationMethod(.stepEnd)
/// Displaying the dashed line and point mark for selected date on the graph.
if let selectedDate = selectedDate, let selectedPrice = history[selectedDate] {
RuleMark(
x: .value("Date", selectedDate)
)
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3]))
PointMark(
x: .value("Date", selectedDate),
y: .value("Price", selectedPrice.doubleValue)
)
.symbol {
Circle()
.fill(Color(uiColor: Self.blue600))
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(Color(uiColor: Self.backgroundColor), lineWidth: 2)
)
}
.foregroundStyle(Color(uiColor: Self.blue600))
}
}
.chartBackground { chartProxy in
Color(uiColor: Self.backgroundColor)
}
.chartYScale(domain: axisYRange)
.chartYAxis {
/// Setting up Y-axis.
AxisMarks(position: .leading, values: axisTicksY) { price in
if let price = price.as(Double.self) {
if price == axisTicksY.first {
AxisTick(length: .longestLabel, stroke: StrokeStyle(lineWidth: 1))
.foregroundStyle(Color(uiColor: Self.grey200))
} else {
AxisValueLabel(
format: .currency(code: currency).precision(.fractionLength(0)))
AxisTick(stroke: StrokeStyle(lineWidth: 0))
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 1))
.foregroundStyle(Color(uiColor: Self.grey200))
}
}
.chartXScale(domain: axisXRange)
.chartXAxis {
AxisMarks(preset: .aligned, stroke: StrokeStyle(lineWidth: 0))
}
.chartOverlay { proxy in
/// Gesture for selecting date on the graph.
GeometryReader { geometry in
Rectangle().fill(.clear).contentShape(Rectangle())
.onAppear {
if selectedDate == nil {
selectedDate = sortedHistoryDates.last?.key
updateTooltipPosition(geometry: geometry, chart: proxy)
}
}
.onContinuousHover(perform: { phase in
switch phase {
case .active(let location):
updateSelectionData(location: location, geometry: geometry, chart: proxy)
updateTooltipPosition(geometry: geometry, chart: proxy)
case .ended:
break
}
})
.gesture(
/// To avoid conflicts between vertical scrolling and horizontal dragging
/// to display the tooltip, a long press is necessary. Once a long press
/// is detected, the system starts listening for a drag event, which we
/// interpret as the user's intent to horizontally drag the tooltip on the graph.
/// The heuristic for the long press was chosen after manual testing.
LongPressGesture(minimumDuration: 0.07)
.sequenced(before: DragGesture())
.onChanged { value in
if case .second(_, let drag) = value, let drag = drag {
updateSelectionData(location: drag.location, geometry: geometry, chart: proxy)
updateTooltipPosition(geometry: geometry, chart: proxy)
}
}
)
}
}
.overlay(
Group {
if let date = selectedDate, let price = history[date],
let xPosition = selectedXPosition, let chartWidth = chartWidth
{
TooltipView(
currency: currency,
price: price.doubleValue,
date: date,
xPosition: xPosition,
chartWidth: chartWidth
)
}
}
)
.edgesIgnoringSafeArea(.all)
}
/// Updates the selected data when the given `location` is selected inside the given
/// `geometry` and `chart`.
private func updateSelectionData(location: CGPoint, geometry: GeometryProxy, chart: ChartProxy) {
let startX = geometry[chart.plotAreaFrame].origin.x
let currentX = location.x - startX
if let index: Date = chart.value(atX: currentX) {
selectedDate = closestDate(to: index, in: history)
}
}
/// Calculates and updates the tooltip's position based on the selected date
/// and chart geometry.
private func updateTooltipPosition(geometry: GeometryProxy, chart: ChartProxy) {
if let selectedDate = selectedDate {
let startX = geometry[chart.plotAreaFrame].origin.x
if let xPosition = chart.position(forX: selectedDate) {
selectedXPosition = xPosition + startX
}
}
chartWidth = geometry.size.width
}
/// Finds the closest date to the given date from the price history dictionary.
private func closestDate(to date: Date, in dictionary: [Date: NSNumber]) -> Date? {
return dictionary.keys.min(by: {
abs($0.timeIntervalSince(date)) < abs($1.timeIntervalSince(date))
})
}
/// Calculates and returns information about the Y-axis ticks
/// based on the provided sorted array of prices, adjusting them to ensure
/// readability and an appropriate range for the Y-axis.
private func getYAxisTicksInfo(prices: [NSNumber]) -> (
ticks: [Double], range: ClosedRange<Double>
) {
/// Minimum and maximum prices.
var paddedMinPrice = prices.first?.doubleValue ?? 0
var paddedMaxPrice = prices.last?.doubleValue ?? 0
/// Median price.
let medianIndex = ceil(Double(prices.count / 2))
let medianPrice = prices[Int(medianIndex)].doubleValue
/// Calculate the padding for the prices
let padding = max(medianPrice / 10, 1)
paddedMinPrice = max(paddedMinPrice - padding, 0)
paddedMaxPrice += padding
let valueRange = paddedMaxPrice - paddedMinPrice
var tickInterval = valueRange / Double(Self.tickCountY - 1)
var tickLow = paddedMinPrice
/// Ensure the tick interval is a multiple of below values to improve the
/// readability. Bigger values are used when possible.
let multipliers = [100.0, 50.0, 20.0, 10.0, 5.0, 2.0, 1.0]
if let multiplier = multipliers.first(where: { tickInterval >= 2 * $0 }) {
tickInterval = ceil(tickInterval / multiplier) * multiplier
/// Calculate the lowest tick value
tickLow = paddedMinPrice - (tickInterval * Double(Self.tickCountY - 1) - valueRange) / 2
tickLow = floor(tickLow / multiplier) * multiplier
tickLow = max(tickLow, 0)
tickInterval =
ceil((paddedMaxPrice - tickLow) / Double(Self.tickCountY - 1) / multiplier) * multiplier
}
let ticks = (-1..<Self.tickCountY).map { tickLow + Double($0) * tickInterval }
let rangeTail = (ticks.last ?? 0.0) + (tickInterval / 2)
return (ticks, (ticks.first ?? 0.0)...rangeTail)
}
}