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:
@@ -459,7 +459,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -498,7 +498,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -655,7 +655,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -688,7 +688,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -719,7 +719,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -743,7 +743,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -766,7 +766,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
@@ -789,7 +789,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
|
||||
@@ -18,6 +18,9 @@ class ChartsViewModel: ObservableObject {
|
||||
case volatility = "Volatility"
|
||||
case prediction = "Prediction"
|
||||
case yearOverYear = "Year vs Year"
|
||||
case comparison = "Compare"
|
||||
case simulator = "What If"
|
||||
case periodComparison = "Period vs Period"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -34,15 +37,18 @@ class ChartsViewModel: ObservableObject {
|
||||
case .volatility: return "waveform.path.ecg"
|
||||
case .prediction: return "wand.and.stars"
|
||||
case .yearOverYear: return "calendar"
|
||||
case .comparison: return "chart.xyaxis.line"
|
||||
case .simulator: return "slider.horizontal.3"
|
||||
case .periodComparison: return "calendar.badge.clock"
|
||||
}
|
||||
}
|
||||
|
||||
var isPremium: Bool {
|
||||
switch self {
|
||||
case .evolution:
|
||||
case .evolution, .comparison, .periodComparison:
|
||||
return false
|
||||
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
|
||||
.drawdown, .volatility, .prediction, .yearOverYear:
|
||||
.drawdown, .volatility, .prediction, .yearOverYear, .simulator:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -71,6 +77,12 @@ class ChartsViewModel: ObservableObject {
|
||||
return "View 12-month forecasts"
|
||||
case .yearOverYear:
|
||||
return "Compare portfolio values across years"
|
||||
case .comparison:
|
||||
return "Compare sources side by side"
|
||||
case .simulator:
|
||||
return "Simulate portfolio reallocation"
|
||||
case .periodComparison:
|
||||
return "Compare two time periods side by side"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +128,56 @@ class ChartsViewModel: ObservableObject {
|
||||
let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data
|
||||
}
|
||||
|
||||
// MARK: - Comparison chart data models
|
||||
|
||||
enum ComparisonDisplayMode: String, CaseIterable, Identifiable {
|
||||
case indexed = "Base 100"
|
||||
case returnPct = "Return %"
|
||||
case absolute = "Value"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct ComparisonSeries: Identifiable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let colorHex: String
|
||||
let points: [(date: Date, value: Double)]
|
||||
}
|
||||
|
||||
@Published var comparisonData: [ComparisonSeries] = []
|
||||
@Published var comparisonSelectedSourceIds: Set<UUID> = []
|
||||
@Published var comparisonDisplayMode: ComparisonDisplayMode = .indexed
|
||||
@Published var comparisonAvailableSources: [InvestmentSource] = []
|
||||
|
||||
// MARK: - Simulator chart data models
|
||||
|
||||
struct SimulatorSource: Identifiable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let currentPct: Double
|
||||
var simulatedPct: Double
|
||||
let colorHex: String
|
||||
}
|
||||
|
||||
@Published var simulatorSources: [SimulatorSource] = []
|
||||
@Published var simulatorActualData: [(date: Date, value: Double)] = []
|
||||
@Published var simulatorData: [(date: Date, value: Double)] = []
|
||||
|
||||
// MARK: - Period comparison data models
|
||||
|
||||
struct PeriodSeries: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let colorHex: String // e.g. "#0000FF" for blue, "#FF8000" for orange
|
||||
let points: [(monthOffset: Int, returnPct: Double)]
|
||||
}
|
||||
|
||||
@Published var periodAStart: Date = Calendar.current.date(byAdding: .month, value: -6, to: Date()) ?? Date()
|
||||
@Published var periodAEnd: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||
@Published var periodBStart: Date = Calendar.current.date(byAdding: .month, value: -18, to: Date()) ?? Date()
|
||||
@Published var periodBEnd: Date = Calendar.current.date(byAdding: .month, value: -13, to: Date()) ?? Date()
|
||||
@Published var periodComparisonData: [PeriodSeries] = []
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showingPaywall = false
|
||||
@Published private var predictionMonthsAhead = 12
|
||||
@@ -255,6 +317,51 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Comparison chart: react to source selection and display mode changes
|
||||
Publishers.CombineLatest($comparisonSelectedSourceIds, $comparisonDisplayMode)
|
||||
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.selectedChartType == .comparison else { return }
|
||||
self.isLoading = true
|
||||
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
var snapshots: [Snapshot]
|
||||
if let cached = self.cachedSnapshots {
|
||||
snapshots = cached
|
||||
} else {
|
||||
snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
|
||||
snapshots = self.freemiumValidator.filterSnapshots(snapshots)
|
||||
}
|
||||
var filtered = snapshots
|
||||
if let cutoff = self.selectedTimeRange.startDate() {
|
||||
filtered = snapshots.filter { $0.date >= cutoff }
|
||||
}
|
||||
self.calculateComparisonData(sources: allSources, allSnapshots: filtered)
|
||||
self.isLoading = false
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Simulator chart: react to slider changes
|
||||
$simulatorSources
|
||||
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] sources in
|
||||
guard let self, self.selectedChartType == .simulator, !sources.isEmpty else { return }
|
||||
self.recalculateSimulatedLine(sources: sources)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Period comparison: react to date picker changes
|
||||
Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd)
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.selectedChartType == .periodComparison else { return }
|
||||
let allSources = self.sourceRepository.sources.filter { self.shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let snapshots = self.snapshotRepository.fetchSnapshots(for: allSourceIds, months: self.maxHistoryMonths)
|
||||
self.calculatePeriodComparisonData(allSnapshots: snapshots)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
@@ -291,8 +398,10 @@ class ChartsViewModel: ObservableObject {
|
||||
switch chartType {
|
||||
case .evolution, .performance:
|
||||
return [.all, .yearToDate, .year, .quarter]
|
||||
case .yearOverYear:
|
||||
return [] // No time range filter for YoY — always uses all data
|
||||
case .yearOverYear, .simulator, .periodComparison:
|
||||
return [] // No time range filter for these charts
|
||||
case .comparison:
|
||||
return [.month, .quarter, .halfYear, .year, .yearToDate, .all]
|
||||
default:
|
||||
return [.month, .quarter, .halfYear, .year, .all]
|
||||
}
|
||||
@@ -383,6 +492,35 @@ class ChartsViewModel: ObservableObject {
|
||||
calculatePredictionData(from: completedSnapshots)
|
||||
case .yearOverYear:
|
||||
calculateYearOverYearData(from: completedSnapshots)
|
||||
case .comparison:
|
||||
// Use ALL sources, not filtered by selectedSource/category
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
comparisonAvailableSources = allSources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
var allSnapshots: [Snapshot]
|
||||
if let cached = cachedSnapshots {
|
||||
allSnapshots = cached
|
||||
} else {
|
||||
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
allSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
||||
}
|
||||
var filteredForComparison = allSnapshots
|
||||
if let cutoff = timeRange.startDate() {
|
||||
filteredForComparison = allSnapshots.filter { $0.date >= cutoff }
|
||||
}
|
||||
calculateComparisonData(sources: allSources, allSnapshots: filteredForComparison)
|
||||
case .simulator:
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
calculateSimulatorData(sources: allSources, allSnapshots: allSnapshots)
|
||||
case .periodComparison:
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
calculatePeriodComparisonData(allSnapshots: allSnapshots)
|
||||
}
|
||||
|
||||
if let selected = selectedCategory,
|
||||
@@ -406,6 +544,8 @@ class ChartsViewModel: ObservableObject {
|
||||
switch chartType {
|
||||
case .evolution, .prediction, .allocation, .performance:
|
||||
return filtered
|
||||
case .comparison, .simulator, .periodComparison:
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -419,6 +559,8 @@ class ChartsViewModel: ObservableObject {
|
||||
switch chartType {
|
||||
case .evolution, .allocation, .performance:
|
||||
return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
case .comparison, .simulator, .periodComparison:
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
@@ -1003,6 +1145,305 @@ class ChartsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparison Chart Calculation
|
||||
|
||||
func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
|
||||
let colorHexes = Self.sourceColorHexes
|
||||
let sortedSources = sources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
|
||||
var result: [ComparisonSeries] = []
|
||||
|
||||
for (index, source) in sortedSources.enumerated() {
|
||||
guard let sourceId = source.id as UUID?,
|
||||
comparisonSelectedSourceIds.contains(sourceId) else { continue }
|
||||
|
||||
let sourceSnapshots = allSnapshots
|
||||
.filter { $0.source?.id == sourceId }
|
||||
.sorted { $0.date < $1.date }
|
||||
|
||||
// Group by month, take latest per month
|
||||
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthlyValues: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthlyValues.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
|
||||
}
|
||||
monthlyValues.sort { $0.date < $1.date }
|
||||
guard !monthlyValues.isEmpty else { continue }
|
||||
|
||||
let firstValue = monthlyValues[0].value
|
||||
let points: [(date: Date, value: Double)]
|
||||
switch comparisonDisplayMode {
|
||||
case .indexed:
|
||||
points = monthlyValues.map { item in
|
||||
let v = firstValue > 0 ? (item.value / firstValue) * 100.0 : 0
|
||||
return (date: item.date, value: v)
|
||||
}
|
||||
case .returnPct:
|
||||
points = monthlyValues.map { item in
|
||||
let v = firstValue > 0 ? ((item.value - firstValue) / firstValue) * 100.0 : 0
|
||||
return (date: item.date, value: v)
|
||||
}
|
||||
case .absolute:
|
||||
points = monthlyValues
|
||||
}
|
||||
|
||||
let colorHex = colorHexes[index % colorHexes.count]
|
||||
result.append(ComparisonSeries(
|
||||
id: sourceId,
|
||||
name: source.name,
|
||||
colorHex: colorHex,
|
||||
points: points
|
||||
))
|
||||
}
|
||||
|
||||
comparisonData = result
|
||||
}
|
||||
|
||||
// MARK: - Simulator Chart Calculation
|
||||
|
||||
func calculateSimulatorData(sources: [InvestmentSource], allSnapshots: [Snapshot]) {
|
||||
guard !sources.isEmpty else {
|
||||
simulatorSources = []
|
||||
simulatorActualData = []
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
|
||||
let colorHexes = Self.sourceColorHexes
|
||||
let sortedSources = sources.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
|
||||
// Build monthly totals per source
|
||||
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
|
||||
for source in sortedSources {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
let sourceSnapshots = allSnapshots
|
||||
.filter { $0.source?.id == sourceId }
|
||||
.sorted { $0.date < $1.date }
|
||||
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthly: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
|
||||
}
|
||||
monthly.sort { $0.date < $1.date }
|
||||
sourceMonthlyValues[sourceId] = monthly
|
||||
}
|
||||
|
||||
// Gather all unique months across sources
|
||||
var allMonthsSet = Set<Date>()
|
||||
for monthly in sourceMonthlyValues.values {
|
||||
monthly.forEach { allMonthsSet.insert($0.date) }
|
||||
}
|
||||
let allMonths = allMonthsSet.sorted()
|
||||
guard !allMonths.isEmpty else {
|
||||
simulatorActualData = []
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
|
||||
// Build actual total portfolio per month (forward-fill per source)
|
||||
var lastValueBySource: [UUID: Double] = [:]
|
||||
var actualTotals: [(date: Date, value: Double)] = []
|
||||
for month in allMonths {
|
||||
for source in sortedSources {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
if let monthly = sourceMonthlyValues[sourceId],
|
||||
let entry = monthly.first(where: { $0.date == month }) {
|
||||
lastValueBySource[sourceId] = entry.value
|
||||
}
|
||||
}
|
||||
let total = lastValueBySource.values.reduce(0, +)
|
||||
actualTotals.append((date: month, value: total))
|
||||
}
|
||||
|
||||
// Normalize actual to Base 100
|
||||
let firstTotal = actualTotals.first?.value ?? 1
|
||||
simulatorActualData = actualTotals.map { item in
|
||||
(date: item.date, value: firstTotal > 0 ? (item.value / firstTotal) * 100.0 : 0)
|
||||
}
|
||||
|
||||
// Compute current weights
|
||||
let latestTotalValue = actualTotals.last?.value ?? 0
|
||||
var simulatorNew: [SimulatorSource] = []
|
||||
for (index, source) in sortedSources.enumerated() {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
let latestSourceValue = sourceMonthlyValues[sourceId]?.last?.value ?? 0
|
||||
let currentPct = latestTotalValue > 0 ? (latestSourceValue / latestTotalValue) * 100.0 : 0
|
||||
// Preserve existing simulatedPct if source already exists in simulatorSources
|
||||
let existingPct = simulatorSources.first(where: { $0.id == sourceId })?.simulatedPct ?? currentPct
|
||||
simulatorNew.append(SimulatorSource(
|
||||
id: sourceId,
|
||||
name: source.name,
|
||||
currentPct: currentPct,
|
||||
simulatedPct: existingPct,
|
||||
colorHex: colorHexes[index % colorHexes.count]
|
||||
))
|
||||
}
|
||||
|
||||
// Only update if sources changed (avoid resetting sliders on normal refresh)
|
||||
if simulatorSources.map({ $0.id }) != simulatorNew.map({ $0.id }) {
|
||||
simulatorSources = simulatorNew
|
||||
} else {
|
||||
// Update currentPct but preserve simulatedPct
|
||||
simulatorSources = simulatorNew
|
||||
}
|
||||
|
||||
recalculateSimulatedLine(sources: simulatorSources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
|
||||
}
|
||||
|
||||
func recalculateSimulatedLine(sources: [SimulatorSource]) {
|
||||
// Re-fetch source monthly values for recalculation from current allSources
|
||||
let allSources = sourceRepository.sources.filter { shouldIncludeSource($0) }
|
||||
let allSourceIds = allSources.compactMap { $0.id }
|
||||
let allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
|
||||
|
||||
var sourceMonthlyValues: [UUID: [(date: Date, value: Double)]] = [:]
|
||||
for source in allSources {
|
||||
guard let sourceId = source.id as UUID? else { continue }
|
||||
let sourceSnapshots = allSnapshots
|
||||
.filter { $0.source?.id == sourceId }
|
||||
.sorted { $0.date < $1.date }
|
||||
let grouped = Dictionary(grouping: sourceSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthly: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
guard let latest = snaps.max(by: { $0.date < $1.date }) else { continue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthly.append((date: date, value: NSDecimalNumber(decimal: latest.decimalValue).doubleValue))
|
||||
}
|
||||
monthly.sort { $0.date < $1.date }
|
||||
sourceMonthlyValues[sourceId] = monthly
|
||||
}
|
||||
|
||||
var allMonthsSet = Set<Date>()
|
||||
for monthly in sourceMonthlyValues.values {
|
||||
monthly.forEach { allMonthsSet.insert($0.date) }
|
||||
}
|
||||
let allMonths = allMonthsSet.sorted()
|
||||
recalculateSimulatedLine(sources: sources, sourceMonthlyValues: sourceMonthlyValues, allMonths: allMonths)
|
||||
}
|
||||
|
||||
private func recalculateSimulatedLine(
|
||||
sources: [SimulatorSource],
|
||||
sourceMonthlyValues: [UUID: [(date: Date, value: Double)]],
|
||||
allMonths: [Date]
|
||||
) {
|
||||
guard !allMonths.isEmpty, !sources.isEmpty else {
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize weights
|
||||
let totalSimulated = sources.reduce(0) { $0 + $1.simulatedPct }
|
||||
guard totalSimulated > 0 else {
|
||||
simulatorData = []
|
||||
return
|
||||
}
|
||||
let normalizedWeights: [UUID: Double] = Dictionary(
|
||||
uniqueKeysWithValues: sources.map { ($0.id, $0.simulatedPct / totalSimulated) }
|
||||
)
|
||||
|
||||
// Build per-source monthly return series
|
||||
var sourceReturns: [UUID: [Date: Double]] = [:]
|
||||
for source in sources {
|
||||
guard let monthly = sourceMonthlyValues[source.id], monthly.count >= 2 else { continue }
|
||||
var returns: [Date: Double] = [:]
|
||||
for i in 1..<monthly.count {
|
||||
let prev = monthly[i - 1].value
|
||||
let curr = monthly[i].value
|
||||
if prev > 0 {
|
||||
returns[monthly[i].date] = (curr - prev) / prev
|
||||
}
|
||||
}
|
||||
sourceReturns[source.id] = returns
|
||||
}
|
||||
|
||||
// Simulate portfolio
|
||||
var simulated = 100.0
|
||||
var simulatedSeries: [(date: Date, value: Double)] = []
|
||||
if let firstMonth = allMonths.first {
|
||||
simulatedSeries.append((date: firstMonth, value: simulated))
|
||||
}
|
||||
for i in 1..<allMonths.count {
|
||||
let month = allMonths[i]
|
||||
var portfolioReturn = 0.0
|
||||
for source in sources {
|
||||
let weight = normalizedWeights[source.id] ?? 0
|
||||
let ret = sourceReturns[source.id]?[month] ?? 0
|
||||
portfolioReturn += weight * ret
|
||||
}
|
||||
simulated *= (1 + portfolioReturn)
|
||||
simulatedSeries.append((date: month, value: simulated))
|
||||
}
|
||||
|
||||
simulatorData = simulatedSeries
|
||||
}
|
||||
|
||||
// MARK: - Period Comparison Chart Calculation
|
||||
|
||||
func calculatePeriodComparisonData(allSnapshots: [Snapshot]) {
|
||||
func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? {
|
||||
let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end }
|
||||
guard !periodSnapshots.isEmpty else { return nil }
|
||||
|
||||
// Group by month, compute portfolio total
|
||||
let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in
|
||||
chartMonth(for: snap.date)
|
||||
}
|
||||
var monthlyTotalsArr: [(date: Date, value: Double)] = []
|
||||
for (key, snaps) in grouped {
|
||||
var latestBySource: [UUID: Snapshot] = [:]
|
||||
for snap in snaps {
|
||||
guard let sourceId = snap.source?.id else { continue }
|
||||
if let existing = latestBySource[sourceId] {
|
||||
if snap.date > existing.date { latestBySource[sourceId] = snap }
|
||||
} else {
|
||||
latestBySource[sourceId] = snap
|
||||
}
|
||||
}
|
||||
let total = latestBySource.values.reduce(0.0) { $0 + NSDecimalNumber(decimal: $1.decimalValue).doubleValue }
|
||||
let date = Calendar.current.date(from: key) ?? Date()
|
||||
monthlyTotalsArr.append((date: date, value: total))
|
||||
}
|
||||
monthlyTotalsArr.sort { $0.date < $1.date }
|
||||
guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil }
|
||||
|
||||
let points = monthlyTotalsArr.enumerated().map { index, item in
|
||||
(monthOffset: index, returnPct: ((item.value - firstValue) / firstValue) * 100.0)
|
||||
}
|
||||
return PeriodSeries(id: id, label: label, colorHex: colorHex, points: points)
|
||||
}
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "MMM yyyy"
|
||||
|
||||
let labelA = "\(dateFormatter.string(from: periodAStart))–\(dateFormatter.string(from: periodAEnd))"
|
||||
let labelB = "\(dateFormatter.string(from: periodBStart))–\(dateFormatter.string(from: periodBEnd))"
|
||||
|
||||
var result: [PeriodSeries] = []
|
||||
if let seriesA = seriesForPeriod(start: periodAStart, end: periodAEnd,
|
||||
label: labelA, colorHex: "#3478F6", id: "A") {
|
||||
result.append(seriesA)
|
||||
}
|
||||
if let seriesB = seriesForPeriod(start: periodBStart, end: periodBEnd,
|
||||
label: labelB, colorHex: "#FF9500", id: "B") {
|
||||
result.append(seriesB)
|
||||
}
|
||||
periodComparisonData = result
|
||||
}
|
||||
|
||||
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
|
||||
let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in
|
||||
chartMonthStart(for: snapshot.date)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user