Feature: rellenar huecos entre snapshots en las gráficas (interpolación lineal)
Nueva utilidad ChartGapFill: para cada source, rellena los meses sin snapshot entre dos conocidos con interpolación lineal (Feb=100k, Jun=130k → Mar/Abr/May ~110/120/130k), arrastra el último valor tras el último snapshot y no aporta antes del primero. Solo afecta al pintado de gráficas — NO crea ni modifica snapshots (cero riesgo para la sync iCloud). Aplicado a las evolution del Dashboard (total + por categoría) y de Charts. Toggle en Settings "Smooth Gaps in Charts" (ON por defecto), localizado en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Fills gaps between snapshots for chart display only — it never creates or
|
||||
/// mutates Core Data objects. A source that has a snapshot in February and the
|
||||
/// next in June leaves March/April/May empty; without filling, per-source and
|
||||
/// category lines dip or break. This computes a value for every month in the
|
||||
/// grid so the graphs read continuously.
|
||||
///
|
||||
/// - Internal gaps (between two known snapshots) are linearly interpolated when
|
||||
/// `interpolate` is true, or carried forward (stepped) when false.
|
||||
/// - After a source's last snapshot the value is carried forward (there is no
|
||||
/// future point to interpolate towards) — matches the portfolio total.
|
||||
/// - Before a source's first snapshot the value is `nil` (the source did not
|
||||
/// exist yet, so it must not contribute 0 and drag the total down).
|
||||
enum ChartGapFill {
|
||||
|
||||
/// The user-facing toggle (Settings). Defaults to ON so charts look right out
|
||||
/// of the box; reading via `object(forKey:)` so an unset key means enabled.
|
||||
static let defaultsKey = "smoothChartGaps"
|
||||
|
||||
static var isEnabled: Bool {
|
||||
(UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true
|
||||
}
|
||||
|
||||
/// For a single source: `known` maps a month index (position in the ordered
|
||||
/// month grid) to that month's value. Returns a value per month index, with
|
||||
/// internal gaps filled and a trailing carry-forward. `nil` means "absent".
|
||||
static func denseValues(
|
||||
known: [Int: Decimal],
|
||||
monthCount: Int,
|
||||
interpolate: Bool
|
||||
) -> [Decimal?] {
|
||||
var result = [Decimal?](repeating: nil, count: monthCount)
|
||||
let indices = known.keys.sorted()
|
||||
guard let first = indices.first, let last = indices.last else { return result }
|
||||
|
||||
for i in indices { result[i] = known[i] }
|
||||
|
||||
if interpolate {
|
||||
// Linear interpolation across each internal gap.
|
||||
for k in 0..<(indices.count - 1) {
|
||||
let a = indices[k]
|
||||
let b = indices[k + 1]
|
||||
guard b - a > 1, let va = known[a], let vb = known[b] else { continue }
|
||||
for i in (a + 1)..<b {
|
||||
let frac = Decimal(i - a) / Decimal(b - a)
|
||||
result[i] = va + (vb - va) * frac
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Stepped carry-forward within internal gaps.
|
||||
var lastVal = known[first] ?? .zero
|
||||
for i in first...last {
|
||||
if let v = result[i] { lastVal = v } else { result[i] = lastVal }
|
||||
}
|
||||
}
|
||||
|
||||
// Carry the last known value forward past the final snapshot.
|
||||
if let lastVal = known[last], last + 1 < monthCount {
|
||||
for i in (last + 1)..<monthCount { result[i] = lastVal }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user