From 66a2b5fbce78fd16a6d3bd07eeb5567272304830 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:29 +0200 Subject: [PATCH 1/4] Render Apple line charts with native vectorized plots --- .../StrandDesign/OverviewHRChart.swift | 97 +++++++++++-------- .../Sources/StrandDesign/TrendChart.swift | 40 +++++--- 2 files changed, 82 insertions(+), 55 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift index fd386555fe..75ca895a95 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -268,50 +268,63 @@ public struct OverviewHRChart: View { // overlay (below) via the proxy — Swift Charts' `.annotation` overflow-clamping needs macOS 14 // and gets clipped by the card's fixed height on 13, so we position labels ourselves. - @ChartContentBuilder private var marks: some ChartContent { - // Styles depend on the chart, not the individual sample. Share them across marks so a - // dense HR series does not recreate the same gradient for every line and area vertex. - let areaFill = LinearGradient( - colors: [StrandPalette.sample(stops: gradient.toStops(), at: unit(averageValue)).opacity(0.28), .clear], - startPoint: .top, endPoint: .bottom) - let lineStroke = valueGradient - // Sleep band — shaded region behind the curve (drawn first so the HR line/area sit on top). - if let sleep, sleep.end > xDomain.lowerBound { - RectangleMark( - xStart: .value("Sleep start", clampX(sleep.start)), - xEnd: .value("Sleep end", clampX(sleep.end)) - ) - .foregroundStyle(StrandPalette.sleepDeep.opacity(0.32)) - } + private var marks: some ChartContent { + Plot { + // Styles depend on the chart, not the individual sample. Share them across marks so a + // dense HR series does not recreate the same gradient for every line and area vertex. + let areaFill = LinearGradient( + colors: [StrandPalette.sample(stops: gradient.toStops(), at: unit(averageValue)).opacity(0.28), .clear], + startPoint: .top, endPoint: .bottom) + let lineStroke = valueGradient + // Sleep band — shaded region behind the curve (drawn first so the HR line/area sit on top). + if let sleep, sleep.end > xDomain.lowerBound { + RectangleMark( + xStart: .value("Sleep start", clampX(sleep.start)), + xEnd: .value("Sleep end", clampX(sleep.end)) + ) + .foregroundStyle(StrandPalette.sleepDeep.opacity(0.32)) + } - ForEach(displayPoints) { p in - AreaMark(x: .value("Time", p.date), y: .value("BPM", p.value)) - .interpolationMethod(.catmullRom) - .foregroundStyle(areaFill) - } - ForEach(displayPoints) { p in - LineMark(x: .value("Time", p.date), y: .value("BPM", p.value)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - .foregroundStyle(lineStroke) - } + // Vectorized plots retain the same samples and styles without a view per vertex. + if #available(iOS 18, macOS 15, *) { + AreaPlot(displayPoints, x: .value("Time", \.date), y: .value("BPM", \.value)) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + LinePlot(displayPoints, x: .value("Time", \.date), y: .value("BPM", \.value)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } else { + ForEach(displayPoints) { p in + AreaMark(x: .value("Time", p.date), y: .value("BPM", p.value)) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + } + ForEach(displayPoints) { p in + LineMark(x: .value("Time", p.date), y: .value("BPM", p.value)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } + } - // Wake divider — the sleep→day boundary. Always shown with a sleep band so the band reads - // even before recovery calibrates (when the gold recovery rule is absent). - if let sleep, sleep.end > xDomain.lowerBound, sleep.end < xDomain.upperBound { - RuleMark(x: .value("Wake", clampX(sleep.end))) - .foregroundStyle(StrandPalette.sleepLight.opacity(0.5)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) - } - if let recovery { - RuleMark(x: .value("Recovery", clampX(recovery.date))) - .foregroundStyle(recovery.color.opacity(0.85)) - .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) - } - if let effort { - RuleMark(x: .value("Effort", clampX(effort.date))) - .foregroundStyle(effort.color.opacity(0.85)) - .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + // Wake divider — the sleep→day boundary. Always shown with a sleep band so the band reads + // even before recovery calibrates (when the gold recovery rule is absent). + if let sleep, sleep.end > xDomain.lowerBound, sleep.end < xDomain.upperBound { + RuleMark(x: .value("Wake", clampX(sleep.end))) + .foregroundStyle(StrandPalette.sleepLight.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + if let recovery { + RuleMark(x: .value("Recovery", clampX(recovery.date))) + .foregroundStyle(recovery.color.opacity(0.85)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + } + if let effort { + RuleMark(x: .value("Effort", clampX(effort.date))) + .foregroundStyle(effort.color.opacity(0.85)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + } } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift index 9f012b57a5..0ffafa6f5f 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift @@ -357,27 +357,41 @@ public struct TrendChart: View { .foregroundStyle(StrandPalette.textSecondary) } } else { - if showsArea { + if #available(iOS 18, macOS 15, *) { + if showsArea { + AreaPlot(displayPoints, x: .value("Date", \.date), y: .value("Value", \.value), + series: .value("Segment", \.segment)) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + } + LinePlot(displayPoints, x: .value("Date", \.date), y: .value("Value", \.value), + series: .value("Segment", \.segment)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } else { + if showsArea { + ForEach(displayPoints) { p in + AreaMark( + x: .value("Date", p.date), + y: .value("Value", p.value), + series: .value("Segment", p.segment) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + } + } ForEach(displayPoints) { p in - AreaMark( + LineMark( x: .value("Date", p.date), y: .value("Value", p.value), series: .value("Segment", p.segment) ) .interpolationMethod(.catmullRom) - .foregroundStyle(areaFill) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) } } - ForEach(displayPoints) { p in - LineMark( - x: .value("Date", p.date), - y: .value("Value", p.value), - series: .value("Segment", p.segment) - ) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - .foregroundStyle(lineStroke) - } // 18pt dots are invisible on dense series (e.g. a 365-day year) but still cost the // GPU a mark each — hide them past a threshold; the line carries the data there. The gate // stays on the full `points.count` (≤60 is never downsampled, so displayPoints == points). From ec9ec76b5de120cc4c4df91bd5bc5644b0213ccc Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:48:27 +0200 Subject: [PATCH 2/4] Scope vectorized chart builders to their supported OS versions --- .../StrandDesign/OverviewHRChart.swift | 20 ++++++++----- .../Sources/StrandDesign/TrendChart.swift | 28 +++++++++++-------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift index 75ca895a95..47b0f9fea2 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -268,6 +268,18 @@ public struct OverviewHRChart: View { // overlay (below) via the proxy — Swift Charts' `.annotation` overflow-clamping needs macOS 14 // and gets clipped by the card's fixed height on 13, so we position labels ourselves. + @available(iOS 18, macOS 15, *) + @ChartContentBuilder + private func vectorizedMarks(areaFill: LinearGradient, lineStroke: LinearGradient) -> some ChartContent { + AreaPlot(displayPoints, x: .value("Time", \.date), y: .value("BPM", \.value)) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + LinePlot(displayPoints, x: .value("Time", \.date), y: .value("BPM", \.value)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } + private var marks: some ChartContent { Plot { // Styles depend on the chart, not the individual sample. Share them across marks so a @@ -287,13 +299,7 @@ public struct OverviewHRChart: View { // Vectorized plots retain the same samples and styles without a view per vertex. if #available(iOS 18, macOS 15, *) { - AreaPlot(displayPoints, x: .value("Time", \.date), y: .value("BPM", \.value)) - .interpolationMethod(.catmullRom) - .foregroundStyle(areaFill) - LinePlot(displayPoints, x: .value("Time", \.date), y: .value("BPM", \.value)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - .foregroundStyle(lineStroke) + vectorizedMarks(areaFill: areaFill, lineStroke: lineStroke) } else { ForEach(displayPoints) { p in AreaMark(x: .value("Time", p.date), y: .value("BPM", p.value)) diff --git a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift index 0ffafa6f5f..422dcfd877 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift @@ -305,6 +305,22 @@ public struct TrendChart: View { return showsBars ? min(0, resolvedYDomain.lowerBound)...resolvedYDomain.upperBound : resolvedYDomain } + @available(iOS 18, macOS 15, *) + @ChartContentBuilder + private func vectorizedMarks(areaFill: LinearGradient, lineStroke: LinearGradient) -> some ChartContent { + if showsArea { + AreaPlot(displayPoints, x: .value("Date", \.date), y: .value("Value", \.value), + series: .value("Segment", \.segment)) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + } + LinePlot(displayPoints, x: .value("Date", \.date), y: .value("Value", \.value), + series: .value("Segment", \.segment)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } + public var body: some View { // Resolve against current data so the marker and readout never refer to a removed date. let currentSelection = selectedPoint.flatMap { selected in points.first { $0.date == selected.date } } @@ -358,17 +374,7 @@ public struct TrendChart: View { } } else { if #available(iOS 18, macOS 15, *) { - if showsArea { - AreaPlot(displayPoints, x: .value("Date", \.date), y: .value("Value", \.value), - series: .value("Segment", \.segment)) - .interpolationMethod(.catmullRom) - .foregroundStyle(areaFill) - } - LinePlot(displayPoints, x: .value("Date", \.date), y: .value("Value", \.value), - series: .value("Segment", \.segment)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - .foregroundStyle(lineStroke) + vectorizedMarks(areaFill: areaFill, lineStroke: lineStroke) } else { if showsArea { ForEach(displayPoints) { p in From 071d6c779925c8c24977c5058ae00c6f8a56f193 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:58:34 +0200 Subject: [PATCH 3/4] Resolve overview plot availability outside the chart builder --- .../StrandDesign/OverviewHRChart.swift | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift index 47b0f9fea2..8dbf46900f 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -280,6 +280,26 @@ public struct OverviewHRChart: View { .foregroundStyle(lineStroke) } + // Resolve availability outside Plot's builder for Xcode 26. + private func curveMarks(areaFill: LinearGradient, lineStroke: LinearGradient) -> AnyChartContent { + if #available(iOS 18, macOS 15, *) { + return AnyChartContent(vectorizedMarks(areaFill: areaFill, lineStroke: lineStroke)) + } + return AnyChartContent(Plot { + ForEach(displayPoints) { p in + AreaMark(x: .value("Time", p.date), y: .value("BPM", p.value)) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + } + ForEach(displayPoints) { p in + LineMark(x: .value("Time", p.date), y: .value("BPM", p.value)) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } + }) + } + private var marks: some ChartContent { Plot { // Styles depend on the chart, not the individual sample. Share them across marks so a @@ -298,21 +318,7 @@ public struct OverviewHRChart: View { } // Vectorized plots retain the same samples and styles without a view per vertex. - if #available(iOS 18, macOS 15, *) { - vectorizedMarks(areaFill: areaFill, lineStroke: lineStroke) - } else { - ForEach(displayPoints) { p in - AreaMark(x: .value("Time", p.date), y: .value("BPM", p.value)) - .interpolationMethod(.catmullRom) - .foregroundStyle(areaFill) - } - ForEach(displayPoints) { p in - LineMark(x: .value("Time", p.date), y: .value("BPM", p.value)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - .foregroundStyle(lineStroke) - } - } + curveMarks(areaFill: areaFill, lineStroke: lineStroke) // Wake divider — the sleep→day boundary. Always shown with a sleep band so the band reads // even before recovery calibrates (when the gold recovery rule is absent). From 711c2159565df4ab804f2b0240fb4c6be47bd9e7 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:05:16 +0200 Subject: [PATCH 4/4] Resolve trend plot availability outside the chart builder --- .../Sources/StrandDesign/TrendChart.swift | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift index 422dcfd877..74a1bfd1f7 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift @@ -321,6 +321,36 @@ public struct TrendChart: View { .foregroundStyle(lineStroke) } + // Resolve availability outside Chart's builder for Xcode 26. + private func curveMarks(areaFill: LinearGradient, lineStroke: LinearGradient) -> AnyChartContent { + if #available(iOS 18, macOS 15, *) { + return AnyChartContent(vectorizedMarks(areaFill: areaFill, lineStroke: lineStroke)) + } + return AnyChartContent(Plot { + if showsArea { + ForEach(displayPoints) { p in + AreaMark( + x: .value("Date", p.date), + y: .value("Value", p.value), + series: .value("Segment", p.segment) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaFill) + } + } + ForEach(displayPoints) { p in + LineMark( + x: .value("Date", p.date), + y: .value("Value", p.value), + series: .value("Segment", p.segment) + ) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .foregroundStyle(lineStroke) + } + }) + } + public var body: some View { // Resolve against current data so the marker and readout never refer to a removed date. let currentSelection = selectedPoint.flatMap { selected in points.first { $0.date == selected.date } } @@ -373,31 +403,7 @@ public struct TrendChart: View { .foregroundStyle(StrandPalette.textSecondary) } } else { - if #available(iOS 18, macOS 15, *) { - vectorizedMarks(areaFill: areaFill, lineStroke: lineStroke) - } else { - if showsArea { - ForEach(displayPoints) { p in - AreaMark( - x: .value("Date", p.date), - y: .value("Value", p.value), - series: .value("Segment", p.segment) - ) - .interpolationMethod(.catmullRom) - .foregroundStyle(areaFill) - } - } - ForEach(displayPoints) { p in - LineMark( - x: .value("Date", p.date), - y: .value("Value", p.value), - series: .value("Segment", p.segment) - ) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) - .foregroundStyle(lineStroke) - } - } + curveMarks(areaFill: areaFill, lineStroke: lineStroke) // 18pt dots are invisible on dense series (e.g. a 365-day year) but still cost the // GPU a mark each — hide them past a threshold; the line carries the data there. The gate // stays on the full `points.count` (≤60 is never downsampled, so displayPoints == points).