d38c6943e1
Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu como navegación primaria), sin bubbles permanentes en compact (solo endpoint), Performance a barras horizontales con >6 categorías, línea con gradiente y estética de widget, pinch anclado al centro + doble-tap reset + haptics. Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush; minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con haptic); drill-down por tap en Allocation/Performance → Evolution filtrada (premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas (premium); insight contextual del rango visible (premium). L10n ×7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
98 lines
3.7 KiB
Swift
98 lines
3.7 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
// MARK: - Point value labels for line charts
|
|
//
|
|
// Shared pieces for the "points + value labels" behavior across every line chart
|
|
// in the Charts tab: permanent labels when the series is short (they carry a lot
|
|
// of value at a glance), and a tap/drag selection bubble when it's dense.
|
|
|
|
enum ChartLabels {
|
|
/// A series with at most this many points shows its value labels permanently.
|
|
static let alwaysShowThreshold = 12
|
|
|
|
/// Max permanent labels to render before thinning — fewer on the narrow
|
|
/// iPhone screen where crammed labels overlap and become unreadable.
|
|
static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 }
|
|
|
|
/// Stride between labelled points so at most `maxLabels` show.
|
|
static func stride(count: Int, compact: Bool) -> Int {
|
|
let maxN = maxLabels(compact: compact)
|
|
guard count > maxN else { return 1 }
|
|
return Int(ceil(Double(count) / Double(maxN)))
|
|
}
|
|
|
|
/// Whether point `index` should carry a permanent label: always the first
|
|
/// and last (the endpoints matter most), plus every `stride`-th in between.
|
|
/// On iPhone (compact) permanent labels collide with the axis on the narrow
|
|
/// screen, so only the endpoint keeps its label — exact values live in the
|
|
/// scrub header instead.
|
|
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
|
|
guard count > 0 else { return false }
|
|
if compact {
|
|
return count <= 6 ? true : index == count - 1
|
|
}
|
|
if index == 0 || index == count - 1 { return true }
|
|
let s = stride(count: count, compact: compact)
|
|
// Avoid a label landing right next to the forced last one.
|
|
if index >= count - 1 - (s / 2) { return false }
|
|
return index % s == 0
|
|
}
|
|
|
|
static func compactCurrency(_ value: Decimal) -> String {
|
|
value.compactCurrencyString
|
|
}
|
|
|
|
static func percent(_ value: Double, decimals: Int = 1) -> String {
|
|
String(format: "%+.\(decimals)f%%", value)
|
|
}
|
|
}
|
|
|
|
/// Small capsule bubble used both for permanent point labels and the selection popover.
|
|
struct ChartValueBubble: View {
|
|
let text: String
|
|
var color: Color = .appPrimary
|
|
var prominent: Bool = false
|
|
|
|
var body: some View {
|
|
Text(text)
|
|
.font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold))
|
|
.foregroundColor(prominent ? .white : color)
|
|
.padding(.horizontal, prominent ? 8 : 5)
|
|
.padding(.vertical, prominent ? 4 : 2)
|
|
.background(
|
|
Capsule().fill(prominent ? color : color.opacity(0.12))
|
|
)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
|
|
/// Multi-line selection card (one row per series) used by multi-series charts.
|
|
struct ChartSelectionCard: View {
|
|
let title: String
|
|
let rows: [(label: String, value: String, color: Color)]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(title)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(.secondary)
|
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
|
HStack(spacing: 5) {
|
|
Circle().fill(row.color).frame(width: 6, height: 6)
|
|
Text(row.label)
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
Text(row.value)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
}
|
|
.padding(8)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
|
|
.fixedSize()
|
|
}
|
|
}
|