Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 66 additions & 41 deletions Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
}
}
}

Expand Down
68 changes: 47 additions & 21 deletions Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -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).
Expand Down
Loading