diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift index fd386555fe..8dbf46900f 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -268,50 +268,75 @@ 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)) + @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) + } + + // 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 + // 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. + 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). - 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..74a1bfd1f7 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift @@ -305,6 +305,52 @@ 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) + } + + // 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 } } @@ -357,27 +403,7 @@ public struct TrendChart: View { .foregroundStyle(StrandPalette.textSecondary) } } 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).