Skip to content

Commit 9ed934a

Browse files
ryan-williamsclaude
andcommitted
fix: render stddev bands for all devices, not just the first
Stddev fill regions (±σ shaded areas) were hardcoded to `deviceData[0]`, so only the first selected device got smoothing bands. Loop over all devices instead. Extract `splitBandSegments` helper to deduplicate the gap-splitting logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c1c8ca1 commit 9ed934a

File tree

1 file changed

+85
-118
lines changed

1 file changed

+85
-118
lines changed

www/src/components/AwairChart.tsx

Lines changed: 85 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ type DataWithZorder = Data & { zorder?: number }
2929

3030
const noop = () => {}
3131

32+
/** Split band data into contiguous segments at null gaps (so Plotly doesn't interpolate across gaps) */
33+
function splitBandSegments(timestamps: string[], upper: (number | null)[], lower: (number | null)[]) {
34+
const segments: Array<{ timestamps: string[]; upper: (number | null)[]; lower: (number | null)[] }> = []
35+
let cur: typeof segments[number] | null = null
36+
for (let i = 0; i < timestamps.length; i++) {
37+
if (upper[i] === null || lower[i] === null) {
38+
if (cur && cur.timestamps.length > 0) segments.push(cur)
39+
cur = null
40+
} else {
41+
if (!cur) cur = { timestamps: [], upper: [], lower: [] }
42+
cur.timestamps.push(timestamps[i])
43+
cur.upper.push(upper[i])
44+
cur.lower.push(lower[i])
45+
}
46+
}
47+
if (cur && cur.timestamps.length > 0) segments.push(cur)
48+
return segments
49+
}
50+
3251
export type HasDeviceIdx = { deviceIdx: number }
3352
export type HasMetric = { metric: 'primary' | 'secondary' }
3453

@@ -844,130 +863,78 @@ export const AwairChart = memo(function AwairChart(
844863
}
845864

846865
// Stddev fill regions (±σ shaded areas) - from smoothed data when available
847-
// Primary stddev region (only for first device)
848866
// Split into segments at gaps (null values) so Plotly doesn't interpolate across gaps
849-
if (!isRawData && deviceData.length > 0) {
850-
const d = deviceData[0]
851-
// Use smoothed data for bands when smoothing enabled, otherwise raw
852-
const bandTimestamps = hasSmoothing ? d.smoothedTimestamps : d.timestamps
853-
// Propagate nulls: null + anything = null (avoid JS's null + null = 0)
854-
const bandUpper = hasSmoothing ? d.upperValues : d.avgValues.map((avg, i) =>
855-
avg === null || d.stddevValues[i] === null ? null : avg + d.stddevValues[i]
856-
)
857-
const bandLower = hasSmoothing ? d.lowerValues : d.avgValues.map((avg, i) =>
858-
avg === null || d.stddevValues[i] === null ? null : avg - d.stddevValues[i]
859-
)
860-
861-
// Split data into segments at null values
862-
const segments: Array<{ timestamps: string[]; upper: (number | null)[]; lower: (number | null)[] }> = []
863-
let currentSegment: { timestamps: string[]; upper: (number | null)[]; lower: (number | null)[] } | null = null
864-
865-
for (let i = 0; i < bandTimestamps.length; i++) {
866-
if (bandUpper[i] === null || bandLower[i] === null) {
867-
// Gap point - end current segment
868-
if (currentSegment && currentSegment.timestamps.length > 0) {
869-
segments.push(currentSegment)
870-
}
871-
currentSegment = null
872-
} else {
873-
// Real data point
874-
if (!currentSegment) {
875-
currentSegment = { timestamps: [], upper: [], lower: [] }
876-
}
877-
currentSegment.timestamps.push(bandTimestamps[i])
878-
currentSegment.upper.push(bandUpper[i])
879-
currentSegment.lower.push(bandLower[i])
880-
}
881-
}
882-
// Don't forget the last segment
883-
if (currentSegment && currentSegment.timestamps.length > 0) {
884-
segments.push(currentSegment)
885-
}
867+
if (!isRawData) {
868+
deviceData.forEach(d => {
869+
// Primary stddev bands
870+
const bandTimestamps = hasSmoothing ? d.smoothedTimestamps : d.timestamps
871+
const bandUpper = hasSmoothing ? d.upperValues : d.avgValues.map((avg, i) =>
872+
avg === null || d.stddevValues[i] === null ? null : avg + d.stddevValues[i]
873+
)
874+
const bandLower = hasSmoothing ? d.lowerValues : d.avgValues.map((avg, i) =>
875+
avg === null || d.stddevValues[i] === null ? null : avg - d.stddevValues[i]
876+
)
886877

887-
// Create traces for each segment
888-
segments.forEach((seg, segIdx) => {
889-
traces.push({
890-
x: seg.timestamps,
891-
y: seg.lower,
892-
mode: 'lines',
893-
line: { color: 'transparent' },
894-
name: `${config.label} Lower`,
895-
showlegend: false,
896-
hoverinfo: 'skip',
897-
})
898-
traces.push({
899-
x: seg.timestamps,
900-
y: seg.upper,
901-
fill: 'tonexty',
902-
fillcolor: `${d.primaryLineProps.color}${opacityHex}`,
903-
line: { color: 'transparent' },
904-
mode: 'lines',
905-
name: segIdx === 0 ? `±σ ${config.label}` : `±σ ${config.label} (cont)`,
906-
showlegend: false,
907-
hoverinfo: 'skip',
878+
const segments = splitBandSegments(bandTimestamps, bandUpper, bandLower)
879+
segments.forEach((seg, segIdx) => {
880+
traces.push({
881+
x: seg.timestamps,
882+
y: seg.lower,
883+
mode: 'lines',
884+
line: { color: 'transparent' },
885+
name: `${config.label} Lower`,
886+
showlegend: false,
887+
hoverinfo: 'skip',
888+
})
889+
traces.push({
890+
x: seg.timestamps,
891+
y: seg.upper,
892+
fill: 'tonexty',
893+
fillcolor: `${d.primaryLineProps.color}${opacityHex}`,
894+
line: { color: 'transparent' },
895+
mode: 'lines',
896+
name: segIdx === 0 ? `±σ ${config.label}` : `±σ ${config.label} (cont)`,
897+
showlegend: false,
898+
hoverinfo: 'skip',
899+
})
908900
})
909-
})
910-
}
911901

912-
// Secondary stddev region (only for first device)
913-
// Split into segments at gaps (null values) so Plotly doesn't interpolate across gaps
914-
if (secondaryConfig && !isRawData && deviceData.length > 0) {
915-
const d = deviceData[0]
916-
const bandTimestamps = hasSmoothing ? d.smoothedTimestamps : d.timestamps
917-
const bandUpper = hasSmoothing ? d.secondaryUpperValues : d.secondaryAvgValues.map((avg, i) =>
918-
avg === null || d.secondaryStddevValues[i] === null ? null : avg + d.secondaryStddevValues[i]
919-
)
920-
const bandLower = hasSmoothing ? d.secondaryLowerValues : d.secondaryAvgValues.map((avg, i) =>
921-
avg === null || d.secondaryStddevValues[i] === null ? null : avg - d.secondaryStddevValues[i]
922-
)
923-
924-
// Split data into segments at null values
925-
const segments: Array<{ timestamps: string[]; upper: (number | null)[]; lower: (number | null)[] }> = []
926-
let currentSegment: { timestamps: string[]; upper: (number | null)[]; lower: (number | null)[] } | null = null
902+
// Secondary stddev bands
903+
if (secondaryConfig) {
904+
const secBandTimestamps = hasSmoothing ? d.smoothedTimestamps : d.timestamps
905+
const secBandUpper = hasSmoothing ? d.secondaryUpperValues : d.secondaryAvgValues.map((avg, i) =>
906+
avg === null || d.secondaryStddevValues[i] === null ? null : avg + d.secondaryStddevValues[i]
907+
)
908+
const secBandLower = hasSmoothing ? d.secondaryLowerValues : d.secondaryAvgValues.map((avg, i) =>
909+
avg === null || d.secondaryStddevValues[i] === null ? null : avg - d.secondaryStddevValues[i]
910+
)
927911

928-
for (let i = 0; i < bandTimestamps.length; i++) {
929-
if (bandUpper[i] === null || bandLower[i] === null) {
930-
if (currentSegment && currentSegment.timestamps.length > 0) {
931-
segments.push(currentSegment)
932-
}
933-
currentSegment = null
934-
} else {
935-
if (!currentSegment) {
936-
currentSegment = { timestamps: [], upper: [], lower: [] }
937-
}
938-
currentSegment.timestamps.push(bandTimestamps[i])
939-
currentSegment.upper.push(bandUpper[i])
940-
currentSegment.lower.push(bandLower[i])
912+
const secSegments = splitBandSegments(secBandTimestamps, secBandUpper, secBandLower)
913+
secSegments.forEach((seg, segIdx) => {
914+
traces.push({
915+
x: seg.timestamps,
916+
y: seg.lower,
917+
mode: 'lines',
918+
line: { color: 'transparent' },
919+
name: `${secondaryConfig.label} Lower`,
920+
showlegend: false,
921+
hoverinfo: 'skip',
922+
yaxis: 'y2',
923+
})
924+
traces.push({
925+
x: seg.timestamps,
926+
y: seg.upper,
927+
fill: 'tonexty',
928+
fillcolor: `${d.secondaryLineProps?.color}${opacityHex}`,
929+
line: { color: 'transparent' },
930+
mode: 'lines',
931+
name: segIdx === 0 ? `±σ ${secondaryConfig.label}` : `±σ ${secondaryConfig.label} (cont)`,
932+
showlegend: false,
933+
hoverinfo: 'skip',
934+
yaxis: 'y2',
935+
})
936+
})
941937
}
942-
}
943-
if (currentSegment && currentSegment.timestamps.length > 0) {
944-
segments.push(currentSegment)
945-
}
946-
947-
// Create traces for each segment
948-
segments.forEach((seg, segIdx) => {
949-
traces.push({
950-
x: seg.timestamps,
951-
y: seg.lower,
952-
mode: 'lines',
953-
line: { color: 'transparent' },
954-
name: `${secondaryConfig.label} Lower`,
955-
showlegend: false,
956-
hoverinfo: 'skip',
957-
yaxis: 'y2',
958-
})
959-
traces.push({
960-
x: seg.timestamps,
961-
y: seg.upper,
962-
fill: 'tonexty',
963-
fillcolor: `${d.secondaryLineProps?.color}${opacityHex}`,
964-
line: { color: 'transparent' },
965-
mode: 'lines',
966-
name: `±σ ${secondaryConfig.label}`,
967-
showlegend: false,
968-
hoverinfo: 'skip',
969-
yaxis: 'y2',
970-
})
971938
})
972939
}
973940

0 commit comments

Comments
 (0)