Add 3 interactive chart types: Compare, What If, Period vs Period (build 34)

- Compare (free): multi-source overlay chart, Base 100 / Return % / Value modes,
  chip selector, reactive to time range filter
- What If (premium): allocation simulator with sliders per source, dual-line chart
  actual vs simulated indexed to 100, orange warning when weights ≠ 100%
- Period vs Period (free): date-picker-driven comparison of any two custom periods,
  return % normalized from month 1 = 0%, color-coded legend with period labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-26 15:32:57 +02:00
parent 270edeb536
commit 12ab2d6009
6 changed files with 1125 additions and 12 deletions
@@ -0,0 +1,241 @@
import SwiftUI
import Charts
struct AllocationSimulatorView: View {
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Allocation Simulator")
.font(.headline)
if viewModel.simulatorSources.isEmpty {
emptyStateView
} else {
allocationCard
chartSection
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Empty State
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("No sources available")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
}
// MARK: - Allocation Card
private var allocationCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Adjust Allocation")
.font(.subheadline.weight(.semibold))
Spacer()
totalAllocationBadge
resetButton
}
ForEach(Array(viewModel.simulatorSources.enumerated()), id: \.element.id) { index, source in
sourceSliderRow(index: index, source: source)
}
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private var totalAllocationBadge: some View {
let total = viewModel.simulatorSources.reduce(0) { $0 + $1.simulatedPct }
let isNear100 = abs(total - 100) < 5
return Text(String(format: "%.0f%%", total))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
.foregroundColor(isNear100 ? .green : .orange)
.cornerRadius(8)
}
private var resetButton: some View {
Button {
var updated = viewModel.simulatorSources
for i in updated.indices {
updated[i] = ChartsViewModel.SimulatorSource(
id: updated[i].id,
name: updated[i].name,
currentPct: updated[i].currentPct,
simulatedPct: updated[i].currentPct,
colorHex: updated[i].colorHex
)
}
viewModel.simulatorSources = updated
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
private func sourceSliderRow(index: Int, source: ChartsViewModel.SimulatorSource) -> some View {
VStack(spacing: 4) {
HStack {
Circle()
.fill(Color(hex: source.colorHex) ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption.weight(.medium))
.lineLimit(1)
Spacer()
Text(String(format: "%.0f%%", source.simulatedPct))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.frame(width: 36, alignment: .trailing)
Text(String(format: "(%.0f%%)", source.currentPct))
.font(.caption2)
.foregroundColor(.secondary)
}
Slider(
value: Binding<Double>(
get: { source.simulatedPct },
set: { newValue in
var updated = viewModel.simulatorSources
if index < updated.count {
updated[index] = ChartsViewModel.SimulatorSource(
id: source.id,
name: source.name,
currentPct: source.currentPct,
simulatedPct: newValue,
colorHex: source.colorHex
)
viewModel.simulatorSources = updated
}
}
),
in: 0...100,
step: 1
)
.tint(Color(hex: source.colorHex) ?? .appPrimary)
}
}
// MARK: - Chart Section
private var chartSection: some View {
VStack(alignment: .leading, spacing: 8) {
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
Text("Not enough data to simulate.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(height: 220)
} else {
simulationChart
chartLegend
}
}
}
private var simulationChart: some View {
Chart {
ForEach(viewModel.simulatorActualData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", item.value),
series: .value("Series", "Actual")
)
.foregroundStyle(Color.blue)
.interpolationMethod(.catmullRom)
}
ForEach(viewModel.simulatorData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", item.value),
series: .value("Series", "Simulated")
)
.foregroundStyle(Color.orange)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 3]))
.interpolationMethod(.catmullRom)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
.font(.caption2)
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%.0f", d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 220)
.drawingGroup()
}
private var chartLegend: some View {
HStack(spacing: 20) {
legendItem(color: .blue, label: "Actual", dashed: false)
legendItem(color: .orange, label: "Simulated", dashed: true)
Spacer()
}
}
private func legendItem(color: Color, label: String, dashed: Bool) -> some View {
HStack(spacing: 6) {
if dashed {
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 3)
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 3)
}
} else {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 20, height: 3)
}
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
// MARK: - Helpers
private var xAxisStride: Int {
let count = max(viewModel.simulatorActualData.count, viewModel.simulatorData.count)
switch count {
case ...6: return 1
case ...12: return 2
case ...24: return 3
default: return 6
}
}
}
@@ -192,6 +192,10 @@ struct ChartsContainerView: View {
private var hasAnyFilter: Bool {
let chartType = viewModel.selectedChartType
// These chart types manage their own controls internally, no external filter bar needed
if chartType == .comparison || chartType == .simulator || chartType == .periodComparison {
return !viewModel.availableTimeRanges(for: chartType).isEmpty
}
let hasTimeRange = chartType != .allocation && chartType != .riskReturn
let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
let hasSources = !viewModel.availableSources(for: chartType).isEmpty
@@ -498,6 +502,12 @@ struct ChartsContainerView: View {
)
case .yearOverYear:
YearOverYearChartView(series: viewModel.yearOverYearData)
case .comparison:
ComparisonChartView(viewModel: viewModel)
case .simulator:
AllocationSimulatorView(viewModel: viewModel)
case .periodComparison:
PeriodComparisonChartView(viewModel: viewModel)
}
}
}
@@ -0,0 +1,188 @@
import SwiftUI
import Charts
struct ComparisonChartView: View {
@ObservedObject var viewModel: ChartsViewModel
private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Compare Sources")
.font(.headline)
sourceSelector
Picker("", selection: $viewModel.comparisonDisplayMode) {
ForEach(ChartsViewModel.ComparisonDisplayMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
chartContent
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Source Selector
private var sourceSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id)
let chipColor = palette[index % palette.count]
Button {
if isSelected {
viewModel.comparisonSelectedSourceIds.remove(source.id)
} else {
viewModel.comparisonSelectedSourceIds.insert(source.id)
}
} label: {
HStack(spacing: 6) {
Circle()
.fill(chipColor)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(isSelected ? chipColor.opacity(0.15) : Color.gray.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.comparisonSelectedSourceIds.isEmpty {
emptySelectionView
} else if viewModel.comparisonData.isEmpty {
noDataView
} else {
lineChart
}
}
private var emptySelectionView: some View {
VStack(spacing: 12) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("Select 2+ sources to compare")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
}
private var noDataView: some View {
Text("No data available for selected sources in this period.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.frame(height: 260)
}
private var lineChart: some View {
VStack(spacing: 12) {
Chart {
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
let seriesColor = palette[index % palette.count]
ForEach(series.points, id: \.date) { point in
LineMark(
x: .value("Date", point.date),
y: .value("Value", point.value),
series: .value("Source", series.name)
)
.foregroundStyle(seriesColor)
.interpolationMethod(.catmullRom)
}
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
.font(.caption2)
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(yAxisLabel(for: d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.drawingGroup()
legend
}
}
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(palette[index % palette.count])
.frame(width: 20, height: 3)
Text(series.name)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Helpers
private var xAxisStride: Int {
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
switch maxPoints {
case ...6: return 1
case ...12: return 2
case ...24: return 3
default: return 6
}
}
private func yAxisLabel(for value: Double) -> String {
switch viewModel.comparisonDisplayMode {
case .indexed:
return String(format: "%.0f", value)
case .returnPct:
return String(format: "%.1f%%", value)
case .absolute:
return Decimal(value).shortCurrencyString
}
}
}
@@ -0,0 +1,233 @@
import SwiftUI
import Charts
struct PeriodComparisonChartView: View {
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Period vs Period")
.font(.headline)
periodPickersSection
chartContent
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Period Pickers
private var periodPickersSection: some View {
VStack(spacing: 10) {
periodRow(
label: "Period A",
color: Color(hex: "#3478F6") ?? .blue,
start: $viewModel.periodAStart,
end: $viewModel.periodAEnd
)
periodRow(
label: "Period B",
color: Color(hex: "#FF9500") ?? .orange,
start: $viewModel.periodBStart,
end: $viewModel.periodBEnd
)
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private func periodRow(
label: String,
color: Color,
start: Binding<Date>,
end: Binding<Date>
) -> some View {
HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption.weight(.semibold))
.foregroundColor(color)
.frame(width: 58, alignment: .leading)
DatePicker(
"",
selection: Binding<Date>(
get: { firstOfMonth(start.wrappedValue) },
set: { start.wrappedValue = firstOfMonth($0) }
),
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth: .infinity)
Text("")
.font(.caption)
.foregroundColor(.secondary)
DatePicker(
"",
selection: Binding<Date>(
get: { firstOfMonth(end.wrappedValue) },
set: { end.wrappedValue = firstOfMonth($0) }
),
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth: .infinity)
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.periodComparisonData.isEmpty {
emptyView
} else {
VStack(spacing: 12) {
periodChart
legend
}
}
}
private var emptyView: some View {
VStack(spacing: 12) {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("No data available for the selected periods.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
}
// MARK: - Flat data for Chart
private struct PeriodPoint: Identifiable {
let id: String
let seriesId: String
let seriesLabel: String
let colorHex: String
let monthOffset: Int
let returnPct: Double
}
private var flatPoints: [PeriodPoint] {
var result: [PeriodPoint] = []
for series in viewModel.periodComparisonData {
for point in series.points {
result.append(PeriodPoint(
id: "\(series.id)-\(point.monthOffset)",
seriesId: series.id,
seriesLabel: series.label,
colorHex: series.colorHex,
monthOffset: point.monthOffset,
returnPct: point.returnPct
))
}
}
return result
}
private var periodChart: some View {
let points = flatPoints
let seriesIds = viewModel.periodComparisonData.map { $0.id }
let seriesColors: [Color] = viewModel.periodComparisonData.map {
Color(hex: $0.colorHex) ?? .gray
}
return Chart(points) { point in
LineMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct),
series: .value("Period", point.seriesLabel)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Period", point.seriesLabel))
PointMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct)
)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.symbolSize(20)
}
.chartForegroundStyleScale(
domain: viewModel.periodComparisonData.map { $0.label },
range: seriesColors
)
.chartXAxis {
AxisMarks(values: xAxisValues) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel {
if let idx = value.as(Int.self) {
Text("M\(idx + 1)")
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%.1f%%", d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.drawingGroup()
.onChange(of: seriesIds) { _, _ in } // silence unused warning
}
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(viewModel.periodComparisonData) { series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series.colorHex) ?? .gray)
.frame(width: 20, height: 3)
Text(series.label)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Helpers
private var xAxisValues: [Int] {
let maxOffset = viewModel.periodComparisonData
.flatMap { $0.points }
.map { $0.monthOffset }
.max() ?? 0
return Array(0...maxOffset)
}
private func firstOfMonth(_ date: Date) -> Date {
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month], from: date)
components.day = 1
return calendar.date(from: components) ?? date
}
}