diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index b80deec..c23eef4 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -121,9 +121,27 @@ // Reset when switching away from a zone-capable metric or leaving histogram $: if (!canAlignZones) alignZones = false; + // ── Theme-aware colours ────────────────────────────────────────────────── + function getThemeColors() { + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + return { + axis: isDark ? '#71717a' : '#52525b', // zinc-500 / zinc-600 + rule: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)', + tooltipFg: isDark ? '#ffffff' : '#18181b', + tooltipBg: isDark ? '#09090b' : '#ffffff', // text outline backing + ruleY: isDark ? '#3f3f46' : '#d4d4d8', // baseline rule + }; + } + // ── Rendering ──────────────────────────────────────────────────────────── - onMount(() => { renderChart(); }); - onDestroy(() => { chart?.remove(); chart = null; }); + let themeObserver: MutationObserver | null = null; + + onMount(() => { + renderChart(); + themeObserver = new MutationObserver(() => renderChart()); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + }); + onDestroy(() => { chart?.remove(); chart = null; themeObserver?.disconnect(); }); $: if (chartEl) { activeTab; xMode; chartType; histData; histThresholds; alignZones; @@ -162,6 +180,7 @@ function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) { const x = xMode === 'distance' ? 'dist_km' : 't'; + const tc = getThemeColors(); const marks: any[] = []; if (activeTab === 'cadence') { @@ -174,12 +193,14 @@ } marks.push( - Plot.ruleX(data, Plot.pointerX({ x, stroke: 'rgba(255,255,255,0.3)', strokeWidth: 1, strokeDasharray: '4,4' })), - Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: 'white', strokeWidth: 1.5 })), + Plot.ruleX(data, Plot.pointerX({ x, stroke: tc.rule, strokeWidth: 1, strokeDasharray: '4,4' })), + Plot.dot(data, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: tc.tooltipBg, strokeWidth: 1.5 })), Plot.text(data, Plot.pointerX({ x, y: yKey, text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '', - dy: -12, fill: 'white', fontSize: 11, fontWeight: '600', + dy: -12, + fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3, + fontSize: 11, fontWeight: '600', })), ); @@ -193,7 +214,7 @@ return Plot.plot({ width: w, height: h, marginLeft: 48, marginBottom: 32, - style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' }, + style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 }, y: { label: yLabel, grid: true, tickCount: 4 }, marks, @@ -204,6 +225,7 @@ const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`; const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null; const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS; + const tc = getThemeColors(); // ── Zone-aligned: one colored bar per zone ────────────────────────────── if (alignZones && rawZones?.length) { @@ -224,7 +246,7 @@ return Plot.plot({ width: w, height: h, marginLeft: 48, marginBottom: 32, - style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' }, + style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, x: { label: yLabel, grid: false, domain: [clampedZones[0][0], clampedZones[clampedZones.length - 1][1]] }, y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat }, marks: [ @@ -240,7 +262,7 @@ fontSize: 10, fontWeight: '600', dy: -8, }), - Plot.ruleY([0], { stroke: '#52525b' }), + Plot.ruleY([0], { stroke: tc.ruleY }), ], }); } @@ -251,7 +273,7 @@ { y: 'count' }, { x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds }, )), - Plot.ruleY([0], { stroke: '#52525b' }), + Plot.ruleY([0], { stroke: tc.ruleY }), ]; if (rawZones?.length) { @@ -282,7 +304,7 @@ return Plot.plot({ width: w, height: h, marginLeft: 48, marginBottom: 32, - style: { background: 'transparent', color: 'var(--text-4, #a1a1aa)', fontSize: '11px' }, + style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] }, y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat }, marks, diff --git a/site/src/components/MmpChart.svelte b/site/src/components/MmpChart.svelte index 40e3744..7432f1a 100644 --- a/site/src/components/MmpChart.svelte +++ b/site/src/components/MmpChart.svelte @@ -82,6 +82,10 @@ $: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)])); + function getAxisColor() { + return document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa'; + } + function renderChart(data: typeof plotData, cmap: typeof colorMap) { if (!chartEl) return; chartEl.innerHTML = ''; @@ -95,7 +99,7 @@ height: 320, marginLeft: 52, marginBottom: 40, - style: { background: 'transparent', color: '#e4e4e7' }, + style: { background: 'transparent', color: getAxisColor() }, x: { type: 'log', label: 'Duration', @@ -160,7 +164,9 @@ onMount(() => { const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap)); ro.observe(chartEl); - return () => ro.disconnect(); + const mo = new MutationObserver(() => renderChart(currentPlotData, currentColorMap)); + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + return () => { ro.disconnect(); mo.disconnect(); }; }); // ── Toggle helpers ─────────────────────────────────────────────────────────