Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/ChartValueLabels.swift
T
alexandrev-tibco 86c95a9e73 Feedback TestFlight build 58 (build 59): labels, needs-update, period selector, Home masonry
De 7 comentarios de TestFlight:
- Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out
  (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie
  (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie.
  Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling,
  Drawdown, Volatility, Compare, Period, YoY, Prediction.
- 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth
  — el estado es por el MES QUE SE VE (effective month de referenceDate), no
  contra la última completion global. Diff usa el snapshot de ese mes.
- Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado
  consistente con el resto, opciones capadas a los meses de datos disponibles
  (availableHistoryMonths). Ya no deja pedir ventanas sin datos.
- Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho
  (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView.

Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK
→ es el subset del partial-failure, no filtro ni esquema; necesita códigos
internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 20:36:15 +02:00

92 lines
3.4 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.
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
guard count > 0 else { return false }
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()
}
}