-
Notifications
You must be signed in to change notification settings - Fork 58
Perp chart tooltip #1710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Perp chart tooltip #1710
Changes from 3 commits
62c4ddf
92dfd50
b34b9d5
e04a758
497c230
ce7087c
e66d3fe
cfa2847
a49be31
43b3394
8dac718
b618f18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,53 @@ | ||||||||||
| // Copyright (c). Gem Wallet. All rights reserved. | ||||||||||
|
|
||||||||||
| import SwiftUI | ||||||||||
| import Primitives | ||||||||||
| import Formatters | ||||||||||
| import Style | ||||||||||
| import Components | ||||||||||
|
|
||||||||||
| struct CandleTooltipViewModel { | ||||||||||
| struct StyleDefaults { | ||||||||||
| static let label = TextStyle(font: .system(size: .space8), color: Colors.secondaryText, fontWeight: .medium) | ||||||||||
| static let value = TextStyle(font: .caption2, color: Colors.black, fontWeight: .semibold) | ||||||||||
| static let small = TextStyle(font: .system(size: .space8), color: Colors.black, fontWeight: .medium) | ||||||||||
| static let rangePrice = TextStyle(font: .system(size: .space10), color: Colors.black, fontWeight: .semibold) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private let candle: ChartCandleStick | ||||||||||
| private let formatter: CurrencyFormatter | ||||||||||
| private static let volumeFormatter = CurrencyFormatter(type: .abbreviated, currencyCode: Currency.usd.rawValue) | ||||||||||
|
|
||||||||||
| init(candle: ChartCandleStick, formatter: CurrencyFormatter) { | ||||||||||
| self.candle = candle | ||||||||||
| self.formatter = formatter | ||||||||||
| } | ||||||||||
|
|
||||||||||
| var openTitle: TextValue { TextValue(text: "Open", style: StyleDefaults.label) } | ||||||||||
| var openValue: TextValue { TextValue(text: formatter.string(double: candle.open), style: StyleDefaults.value) } | ||||||||||
|
|
||||||||||
| var closeTitle: TextValue { TextValue(text: "Close", style: StyleDefaults.label) } | ||||||||||
| var closeValue: TextValue { | ||||||||||
| TextValue( | ||||||||||
| text: formatter.string(double: candle.close), | ||||||||||
| style: TextStyle(font: .caption2, color: PriceChangeColor.color(for: candle.close - candle.open), fontWeight: .semibold) | ||||||||||
| ) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| var volumeTitle: TextValue { TextValue(text: "Volume", style: StyleDefaults.label) } | ||||||||||
| var volumeValue: TextValue { TextValue(text: Self.volumeFormatter.string(candle.volume * candle.close), style: StyleDefaults.small) } | ||||||||||
|
|
||||||||||
| var rangeBar: RangeBarViewModel { | ||||||||||
| let closePosition: Double = { | ||||||||||
| let range = candle.high - candle.low | ||||||||||
| return range > 0 ? (candle.close - candle.low) / range : 0.5 | ||||||||||
| }() | ||||||||||
| return RangeBarViewModel( | ||||||||||
| lowTitle: TextValue(text: "Low", style: TextStyle(font: .system(size: .space8), color: Colors.red, fontWeight: .medium)), | ||||||||||
| highTitle: TextValue(text: "High", style: TextStyle(font: .system(size: .space8), color: Colors.green, fontWeight: .medium)), | ||||||||||
| lowValue: TextValue(text: formatter.string(double: candle.low), style: StyleDefaults.rangePrice), | ||||||||||
|
||||||||||
| highTitle: TextValue(text: "High", style: TextStyle(font: .system(size: .space8), color: Colors.green, fontWeight: .medium)), | |
| lowValue: TextValue(text: formatter.string(double: candle.low), style: StyleDefaults.rangePrice), | |
| lowTitle: TextValue(text: "Low", style: StyleDefaults.lowTitle), | |
| highTitle: TextValue(text: "High", style: StyleDefaults.highTitle), |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| // Copyright (c). Gem Wallet. All rights reserved. | ||
|
|
||
| import Components | ||
|
|
||
| struct RangeBarViewModel { | ||
| let lowTitle: TextValue | ||
| let highTitle: TextValue | ||
| let lowValue: TextValue | ||
| let highValue: TextValue | ||
| let closePosition: Double | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| // Copyright (c). Gem Wallet. All rights reserved. | ||
|
|
||
| import SwiftUI | ||
| import Style | ||
| import Components | ||
|
|
||
| struct CandleTooltipView: View { | ||
| let model: CandleTooltipViewModel | ||
|
|
||
| var body: some View { | ||
| VStack(spacing: Spacing.extraSmall) { | ||
| HStack { | ||
| VStack(alignment: .leading, spacing: Spacing.extraSmall) { | ||
| Text(model.openTitle.text) | ||
| .textStyle(model.openTitle.style) | ||
| Text(model.openValue.text) | ||
| .textStyle(model.openValue.style) | ||
| } | ||
| Spacer(minLength: Spacing.medium) | ||
| VStack(alignment: .trailing, spacing: Spacing.extraSmall) { | ||
| Text(model.closeTitle.text) | ||
| .textStyle(model.closeTitle.style) | ||
| Text(model.closeValue.text) | ||
| .textStyle(model.closeValue.style) | ||
| } | ||
| } | ||
| RangeBarView(model: model.rangeBar) | ||
| .padding(.bottom, Spacing.extraSmall) | ||
| Divider() | ||
| .padding(.bottom, Spacing.extraSmall) | ||
| HStack { | ||
| Text(model.volumeTitle.text) | ||
| .textStyle(model.volumeTitle.style) | ||
| Spacer() | ||
| Text(model.volumeValue.text) | ||
| .textStyle(model.volumeValue.style) | ||
| } | ||
| } | ||
| .padding(Spacing.small) | ||
| .background(.thickMaterial) | ||
| .clipShape(RoundedRectangle(cornerRadius: Spacing.small)) | ||
| .overlay( | ||
| RoundedRectangle(cornerRadius: Spacing.small) | ||
| .stroke(Colors.black.opacity(0.08), lineWidth: 1) | ||
| ) | ||
| .shadow(color: .black.opacity(0.12), radius: Spacing.small, y: Spacing.tiny) | ||
| .fixedSize() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ import Style | |||||||||||||||||||||
| import Primitives | ||||||||||||||||||||||
| import PrimitivesComponents | ||||||||||||||||||||||
| import Formatters | ||||||||||||||||||||||
| import Components | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private struct ChartKey { | ||||||||||||||||||||||
| static let date = "Date" | ||||||||||||||||||||||
|
|
@@ -23,7 +24,7 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
| private let lineModels: [ChartLineViewModel] | ||||||||||||||||||||||
| private let formatter: CurrencyFormatter | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @State private var selectedCandle: ChartPriceViewModel? { | ||||||||||||||||||||||
| @State private var selectedCandle: ChartCandleStick? { | ||||||||||||||||||||||
| didSet { | ||||||||||||||||||||||
| if let selectedCandle, selectedCandle.date != oldValue?.date { | ||||||||||||||||||||||
| vibrate() | ||||||||||||||||||||||
|
|
@@ -44,26 +45,26 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
| self.lineModels = lineModels | ||||||||||||||||||||||
| self.formatter = formatter | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| var body: some View { | ||||||||||||||||||||||
| VStack { | ||||||||||||||||||||||
| priceHeader | ||||||||||||||||||||||
| chartView(bounds: ChartBounds(candles: data, lines: lineModels)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private var priceHeader: some View { | ||||||||||||||||||||||
| VStack { | ||||||||||||||||||||||
| if let selectedCandle { | ||||||||||||||||||||||
| ChartPriceView(model: selectedCandle) | ||||||||||||||||||||||
| } else if let currentPrice = currentPriceModel { | ||||||||||||||||||||||
| ChartPriceView(model: currentPrice) | ||||||||||||||||||||||
| if let selectedPriceModel { | ||||||||||||||||||||||
| ChartPriceView(model: selectedPriceModel) | ||||||||||||||||||||||
| } else if let currentPriceModel { | ||||||||||||||||||||||
| ChartPriceView(model: currentPriceModel) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| .padding(.top, Spacing.small) | ||||||||||||||||||||||
| .padding(.bottom, Spacing.tiny) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private func chartView(bounds: ChartBounds) -> some View { | ||||||||||||||||||||||
| let dateRange = (data.first?.date ?? Date())...(data.last?.date ?? Date()) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -81,13 +82,17 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
| DragGesture(minimumDistance: 0) | ||||||||||||||||||||||
| .onChanged { value in | ||||||||||||||||||||||
| if let candle = findCandle(location: value.location, proxy: proxy, geometry: geometry) { | ||||||||||||||||||||||
| selectedCandle = createPriceModel(for: candle) | ||||||||||||||||||||||
| selectedCandle = candle | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| .onEnded { _ in | ||||||||||||||||||||||
| selectedCandle = nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if let selectedCandle { | ||||||||||||||||||||||
| tooltipOverlay(for: selectedCandle, proxy: proxy, geometry: geometry) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| .chartXAxis { | ||||||||||||||||||||||
|
|
@@ -133,7 +138,7 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| private var currentPriceColor: Color { | ||||||||||||||||||||||
| guard let lastCandle = data.last else { return Colors.gray } | ||||||||||||||||||||||
| return lastCandle.close >= lastCandle.open ? Colors.green : Colors.red | ||||||||||||||||||||||
| return PriceChangeColor.color(for: lastCandle.close - lastCandle.open) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @ChartContentBuilder | ||||||||||||||||||||||
|
|
@@ -145,15 +150,15 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
| yEnd: .value(ChartKey.high, candle.high) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| .lineStyle(StrokeStyle(lineWidth: 1)) | ||||||||||||||||||||||
| .foregroundStyle(candle.close >= candle.open ? Colors.green : Colors.red) | ||||||||||||||||||||||
| .foregroundStyle(PriceChangeColor.color(for: candle.close - candle.open)) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| RectangleMark( | ||||||||||||||||||||||
| x: .value(ChartKey.date, candle.date), | ||||||||||||||||||||||
| yStart: .value(ChartKey.open, candle.open), | ||||||||||||||||||||||
| yEnd: .value(ChartKey.close, candle.close), | ||||||||||||||||||||||
| width: .fixed(4) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| .foregroundStyle(candle.close >= candle.open ? Colors.green : Colors.red) | ||||||||||||||||||||||
| .foregroundStyle(PriceChangeColor.color(for: candle.close - candle.open)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -192,12 +197,10 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| @ChartContentBuilder | ||||||||||||||||||||||
| private var selectionMarks: some ChartContent { | ||||||||||||||||||||||
| if let selectedCandle, | ||||||||||||||||||||||
| let selectedDate = selectedCandle.date, | ||||||||||||||||||||||
| let selectedCandleData = data.first(where: { abs($0.date.timeIntervalSince(selectedDate)) < 1 }) { | ||||||||||||||||||||||
| if let selectedCandle { | ||||||||||||||||||||||
| PointMark( | ||||||||||||||||||||||
| x: .value(ChartKey.date, selectedDate), | ||||||||||||||||||||||
| y: .value(ChartKey.price, selectedCandleData.close) | ||||||||||||||||||||||
| x: .value(ChartKey.date, selectedCandle.date), | ||||||||||||||||||||||
| y: .value(ChartKey.price, selectedCandle.close) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| .symbol { | ||||||||||||||||||||||
| Circle() | ||||||||||||||||||||||
|
|
@@ -206,59 +209,75 @@ struct CandlestickChartView: View { | |||||||||||||||||||||
| .frame(width: 12) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| RuleMark(x: .value(ChartKey.date, selectedDate)) | ||||||||||||||||||||||
| RuleMark(x: .value(ChartKey.date, selectedCandle.date)) | ||||||||||||||||||||||
| .foregroundStyle(Colors.blue) | ||||||||||||||||||||||
| .lineStyle(StrokeStyle(lineWidth: 1, dash: [5])) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private var currentPriceModel: ChartPriceViewModel? { | ||||||||||||||||||||||
| guard let lastCandle = data.last, | ||||||||||||||||||||||
| let base = basePrice else { return nil } | ||||||||||||||||||||||
| return ChartPriceViewModel( | ||||||||||||||||||||||
| period: period, | ||||||||||||||||||||||
| date: nil, | ||||||||||||||||||||||
| price: lastCandle.close, | ||||||||||||||||||||||
| priceChangePercentage: PriceChangeCalculator.calculate(.percentage(from: base, to: lastCandle.close)), | ||||||||||||||||||||||
| formatter: formatter | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| guard let lastCandle = data.last, let base = basePrice else { return nil } | ||||||||||||||||||||||
| return priceModel(for: lastCandle, base: base) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private var selectedPriceModel: ChartPriceViewModel? { | ||||||||||||||||||||||
| guard let selectedCandle else { return nil } | ||||||||||||||||||||||
| let base = basePrice ?? data.first?.close ?? selectedCandle.close | ||||||||||||||||||||||
| return priceModel(for: selectedCandle, base: base, date: selectedCandle.date) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private func createPriceModel(for candle: ChartCandleStick) -> ChartPriceViewModel { | ||||||||||||||||||||||
| let base = basePrice ?? data.first?.close ?? candle.close | ||||||||||||||||||||||
| return ChartPriceViewModel( | ||||||||||||||||||||||
| private func priceModel(for candle: ChartCandleStick, base: Double, date: Date? = nil) -> ChartPriceViewModel { | ||||||||||||||||||||||
| ChartPriceViewModel( | ||||||||||||||||||||||
| period: period, | ||||||||||||||||||||||
| date: candle.date, | ||||||||||||||||||||||
| date: date, | ||||||||||||||||||||||
| price: candle.close, | ||||||||||||||||||||||
| priceChangePercentage: PriceChangeCalculator.calculate(.percentage(from: base, to: candle.close)), | ||||||||||||||||||||||
| formatter: formatter | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @ViewBuilder | ||||||||||||||||||||||
| private func tooltipOverlay(for candle: ChartCandleStick, proxy: ChartProxy, geometry: GeometryProxy) -> some View { | ||||||||||||||||||||||
| let isRightHalf: Bool = { | ||||||||||||||||||||||
| guard let plotFrame = proxy.plotFrame, | ||||||||||||||||||||||
| let xPosition = proxy.position(forX: candle.date) else { return false } | ||||||||||||||||||||||
| return xPosition > geometry[plotFrame].size.width / 2 | ||||||||||||||||||||||
| }() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| CandleTooltipView(model: CandleTooltipViewModel(candle: candle, formatter: formatter)) | ||||||||||||||||||||||
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRightHalf ? .topLeading : .topTrailing) | ||||||||||||||||||||||
| .padding(.leading, Spacing.small) | ||||||||||||||||||||||
| .padding(.top, Spacing.small) | ||||||||||||||||||||||
| .padding(.trailing, Spacing.extraLarge + Spacing.medium) | ||||||||||||||||||||||
|
Comment on lines
+248
to
+251
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The padding applied to the Consider applying leading padding when
Suggested change
|
||||||||||||||||||||||
| .transition(.opacity) | ||||||||||||||||||||||
| .animation(.easeInOut(duration: 0.15), value: isRightHalf) | ||||||||||||||||||||||
|
||||||||||||||||||||||
| .allowsHitTesting(false) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private func findCandle(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> ChartCandleStick? { | ||||||||||||||||||||||
| guard let plotFrame = proxy.plotFrame else { return nil } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| let relativeXPosition = location.x - geometry[plotFrame].origin.x | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if let date = proxy.value(atX: relativeXPosition) as Date? { | ||||||||||||||||||||||
| // Find the closest candle | ||||||||||||||||||||||
| var minDistance: TimeInterval = .infinity | ||||||||||||||||||||||
| var closestCandle: ChartCandleStick? | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| for candle in data { | ||||||||||||||||||||||
| let distance = abs(candle.date.timeIntervalSince(date)) | ||||||||||||||||||||||
| if distance < minDistance { | ||||||||||||||||||||||
| minDistance = distance | ||||||||||||||||||||||
| closestCandle = candle | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return closestCandle | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private func vibrate() { | ||||||||||||||||||||||
| UIImpactFeedbackGenerator(style: .light).impactOccurred() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // Copyright (c). Gem Wallet. All rights reserved. | ||
|
|
||
| import SwiftUI | ||
| import Style | ||
| import Components | ||
|
|
||
| struct RangeBarView: View { | ||
| let model: RangeBarViewModel | ||
|
|
||
| var body: some View { | ||
| VStack(spacing: Spacing.tiny) { | ||
| HStack { | ||
| Text(model.lowTitle.text).textStyle(model.lowTitle.style) | ||
| Spacer() | ||
| Text(model.highTitle.text).textStyle(model.highTitle.style) | ||
| } | ||
|
|
||
| GeometryReader { geometry in | ||
| ZStack(alignment: .leading) { | ||
| Capsule() | ||
| .fill( | ||
| LinearGradient( | ||
| colors: [Colors.red, Colors.green], | ||
| startPoint: .leading, | ||
| endPoint: .trailing | ||
| ) | ||
| ) | ||
| .frame(height: Spacing.tiny) | ||
|
|
||
| Circle() | ||
| .fill(Colors.whiteSolid) | ||
| .overlay(Circle().stroke(Color.black.opacity(0.2), lineWidth: 1)) | ||
| .frame(width: Spacing.small, height: Spacing.small) | ||
| .offset(x: max(0, min(geometry.size.width - Spacing.small, geometry.size.width * model.closePosition - Spacing.tiny))) | ||
| } | ||
| } | ||
| .frame(height: Spacing.small) | ||
|
|
||
| HStack { | ||
| Text(model.lowValue.text).textStyle(model.lowValue.style) | ||
| Spacer() | ||
| Text(model.highValue.text).textStyle(model.highValue.style) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
currencyCodeforvolumeFormatteris hardcoded toCurrency.usd.rawValue. WhilerawValueprovides the string, it's still a specific currency. If the application is intended to support multiple currencies, this should ideally be dynamic or configurable rather than hardcoded to USD, to prevent potential inconsistencies or future maintenance issues if other currencies need to be displayed for volume.