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
This commit is contained in:
@@ -4,6 +4,16 @@ import Charts
|
||||
struct PerformanceBarChart: View {
|
||||
let data: [(category: String, cagr: Double, color: String)]
|
||||
var title: String = "Performance by Category"
|
||||
/// Drill-down: tapping a row jumps to Evolution filtered to it.
|
||||
var onDrillDown: ((String) -> Void)? = nil
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
/// On iPhone, vertical bars collide once there are more than a handful of
|
||||
/// categories (labels + annotations fight for width). Horizontal bars give
|
||||
/// every name a full line.
|
||||
private var useHorizontalBars: Bool {
|
||||
horizontalSizeClass != .regular && data.count > 6
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -19,60 +29,16 @@ struct PerformanceBarChart: View {
|
||||
}
|
||||
|
||||
if !data.isEmpty {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
if useHorizontalBars {
|
||||
horizontalChart
|
||||
} else {
|
||||
verticalChart
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
|
||||
// Legend / Details
|
||||
// Legend / Details — rows drill down when the owner wires it up.
|
||||
VStack(spacing: 8) {
|
||||
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
legendRow(item)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
@@ -89,6 +55,118 @@ struct PerformanceBarChart: View {
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
}
|
||||
|
||||
private var verticalChart: some View {
|
||||
Chart(data, id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("Category", item.category),
|
||||
y: .value("CAGR", item.cagr)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 250)
|
||||
}
|
||||
|
||||
private var horizontalChart: some View {
|
||||
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
|
||||
BarMark(
|
||||
x: .value("CAGR", item.cagr),
|
||||
y: .value("Category", item.category)
|
||||
)
|
||||
.foregroundStyle(Color(hex: item.color) ?? .gray)
|
||||
.cornerRadius(4)
|
||||
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
|
||||
Text(String(format: "%.1f%%", item.cagr))
|
||||
.font(.caption2)
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let doubleValue = value.as(Double.self) {
|
||||
Text(String(format: "%.0f%%", doubleValue))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let category = value.as(String.self) {
|
||||
Text(category)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: CGFloat(data.count) * 34 + 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
|
||||
let row = HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: item.color) ?? .gray)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(item.category)
|
||||
.font(.subheadline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(String(format: "%.2f%%", item.cagr))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
|
||||
|
||||
if onDrillDown != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
if let onDrillDown {
|
||||
Button {
|
||||
onDrillDown(item.category)
|
||||
} label: {
|
||||
row.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
private var perfStats: [ChartStat] {
|
||||
guard !data.isEmpty else { return [] }
|
||||
let values = data.map { $0.cagr }
|
||||
|
||||
Reference in New Issue
Block a user