159 lines
5.9 KiB
Swift
159 lines
5.9 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
struct EvolutionChartCard: View {
|
|
let data: [(date: Date, value: Decimal)]
|
|
|
|
@State private var selectedDataPoint: (date: Date, value: Decimal)?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Portfolio Evolution")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
if let selected = selectedDataPoint {
|
|
VStack(alignment: .trailing) {
|
|
Text(selected.value.compactCurrencyString)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(selected.date.monthYearString)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
if data.count >= 2 {
|
|
Chart {
|
|
ForEach(data, id: \.date) { item in
|
|
LineMark(
|
|
x: .value("Date", item.date),
|
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
|
)
|
|
.foregroundStyle(Color.appPrimary)
|
|
.interpolationMethod(.catmullRom)
|
|
|
|
AreaMark(
|
|
x: .value("Date", item.date),
|
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
|
)
|
|
.foregroundStyle(
|
|
LinearGradient(
|
|
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.interpolationMethod(.catmullRom)
|
|
}
|
|
|
|
if let selected = selectedDataPoint {
|
|
RuleMark(x: .value("Selected", selected.date))
|
|
.foregroundStyle(Color.gray.opacity(0.3))
|
|
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
|
|
|
|
PointMark(
|
|
x: .value("Date", selected.date),
|
|
y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue)
|
|
)
|
|
.foregroundStyle(Color.appPrimary)
|
|
.symbolSize(100)
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .stride(by: .month, count: 3)) { value in
|
|
AxisValueLabel(format: .dateTime.month(.abbreviated))
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks(position: .leading) { value in
|
|
AxisValueLabel {
|
|
if let doubleValue = value.as(Double.self) {
|
|
Text(Decimal(doubleValue).shortCurrencyString)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.chartOverlay { proxy in
|
|
GeometryReader { geometry in
|
|
Rectangle()
|
|
.fill(.clear)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { value in
|
|
let x = value.location.x - geometry[proxy.plotAreaFrame].origin.x
|
|
guard let date: Date = proxy.value(atX: x) else { return }
|
|
|
|
if let closest = data.min(by: {
|
|
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
|
|
}) {
|
|
selectedDataPoint = closest
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
selectedDataPoint = nil
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
} else {
|
|
Text("Not enough data to display chart")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.frame(height: 200)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mini Sparkline
|
|
|
|
struct SparklineView: View {
|
|
let data: [(date: Date, value: Decimal)]
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
if data.count >= 2 {
|
|
Chart(data, id: \.date) { item in
|
|
LineMark(
|
|
x: .value("Date", item.date),
|
|
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
|
|
)
|
|
.foregroundStyle(color)
|
|
.interpolationMethod(.catmullRom)
|
|
}
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis(.hidden)
|
|
.chartLegend(.hidden)
|
|
} else {
|
|
Rectangle()
|
|
.fill(Color.gray.opacity(0.1))
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let sampleData: [(date: Date, value: Decimal)] = [
|
|
(Date().adding(months: -6), 10000),
|
|
(Date().adding(months: -5), 10500),
|
|
(Date().adding(months: -4), 10200),
|
|
(Date().adding(months: -3), 11000),
|
|
(Date().adding(months: -2), 11500),
|
|
(Date().adding(months: -1), 11200),
|
|
(Date(), 12000)
|
|
]
|
|
|
|
return EvolutionChartCard(data: sampleData)
|
|
.padding()
|
|
}
|