691 lines
27 KiB
Svelte
691 lines
27 KiB
Svelte
<script lang="ts">
|
|
import * as Plot from '@observablehq/plot';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import type { Timeseries, AthleteZones } from '../lib/types';
|
|
|
|
export let timeseries: Timeseries;
|
|
// Linked hover: emit/receive index into timeseries arrays
|
|
export let hoveredIdx: number | null = null;
|
|
export let athlete: AthleteZones | null = null;
|
|
|
|
const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171'];
|
|
const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e'];
|
|
|
|
type Tab = 'elevation' | 'slope' | 'speed' | 'hr' | 'cadence' | 'power';
|
|
type XMode = 'time' | 'distance';
|
|
type ChartType = 'line' | 'histogram';
|
|
type SmoothMode = 'raw' | '10s' | '20s';
|
|
const SMOOTH_HALF: Record<SmoothMode, number> = { raw: 0, '10s': 5, '20s': 10 };
|
|
|
|
let activeTab: Tab = 'elevation';
|
|
let xMode: XMode = 'time';
|
|
let chartType: ChartType = 'line';
|
|
let smoothMode: SmoothMode = 'raw';
|
|
let chartEl: HTMLDivElement;
|
|
let chart: SVGElement | null = null;
|
|
|
|
// Cumulative distance in km, integrated from speed_kmh.
|
|
// Speeds > 150 km/h are treated as 0 (GPS glitch guard) — otherwise a single
|
|
// 1-second spike at 220 km/h pushes all subsequent points ~60 m too far right
|
|
// on the distance axis and stretches the chart out of proportion.
|
|
$: dist_km = (() => {
|
|
if (!timeseries.speed_kmh.some(v => v != null)) return null;
|
|
const d: number[] = [0];
|
|
for (let i = 1; i < timeseries.t.length; i++) {
|
|
const v = timeseries.speed_kmh[i];
|
|
const dt = timeseries.t[i] - timeseries.t[i - 1];
|
|
const prev = d[i - 1];
|
|
// Clamp to 150 km/h; treat null or out-of-range as 0 movement
|
|
const vSafe = (v != null && v > 0 && v <= 150) ? v : 0;
|
|
d.push(prev + vSafe * dt / 3600);
|
|
}
|
|
return d;
|
|
})();
|
|
|
|
// Pre-build data array once
|
|
$: data = timeseries.t.map((t, i) => ({
|
|
t,
|
|
dist_km: dist_km ? dist_km[i] : null,
|
|
elevation: timeseries.elevation_m[i],
|
|
speed: timeseries.speed_kmh[i],
|
|
hr: timeseries.hr_bpm[i],
|
|
cadence: timeseries.cadence_rpm[i],
|
|
power: timeseries.power_w[i],
|
|
}));
|
|
|
|
$: hasHR = timeseries.hr_bpm.some(v => v != null);
|
|
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
|
$: hasElevation = timeseries.elevation_m.some(v => v != null);
|
|
$: hasSpeed = timeseries.speed_kmh.some(v => v != null);
|
|
$: hasPower = timeseries.power_w.some(v => v != null);
|
|
$: hasDistance = dist_km !== null;
|
|
$: hasSlope = hasElevation && hasDistance;
|
|
|
|
const tabLabels: Record<Tab, string> = {
|
|
elevation: 'Elevation',
|
|
slope: 'Slope',
|
|
speed: 'Speed',
|
|
hr: 'Heart Rate',
|
|
cadence: 'Cadence',
|
|
power: 'Power',
|
|
};
|
|
|
|
const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = {
|
|
elevation: { color: '#00c8ff', yLabel: 'Elevation (m)', yKey: 'elevation' },
|
|
slope: { color: '#e4e4e7', yLabel: 'Elevation (m)', yKey: 'elevation' },
|
|
speed: { color: '#ff6b35', yLabel: 'Speed (km/h)', yKey: 'speed' },
|
|
hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' },
|
|
cadence: { color: '#a78bfa', yLabel: 'Cadence (rpm)', yKey: 'cadence' },
|
|
power: { color: '#facc15', yLabel: 'Power (W)', yKey: 'power' },
|
|
};
|
|
|
|
// ── Histogram controls ───────────────────────────────────────────────────
|
|
let bins = 15;
|
|
|
|
// Metric values for current tab (non-null)
|
|
$: yKey = tabMeta[activeTab].yKey;
|
|
$: metricValues = data
|
|
.map(d => (d as any)[yKey] as number | null)
|
|
.filter((v): v is number => v != null);
|
|
$: dataMin = metricValues.length ? Math.min(...metricValues) : 0;
|
|
$: dataMax = metricValues.length ? Math.max(...metricValues) : 100;
|
|
|
|
// Explicit y domain for the line chart.
|
|
// We compute this once from all data and pass it explicitly to Plot so that
|
|
// switching x-axis mode (time ↔ distance) never changes the y range — Observable
|
|
// Plot auto-infers different domains when the x-channel changes because it only
|
|
// considers plottable points, but we want the scale to stay anchored to the
|
|
// full dataset. areaY extends down to 0, so include 0 in the minimum.
|
|
$: lineDomainMin = Math.min(0, dataMin);
|
|
$: lineDomainMax = dataMax;
|
|
|
|
// Range handles — reset whenever the metric or chart type changes
|
|
let trimMin = 0;
|
|
let trimMax = 100;
|
|
let lastResetTab: Tab | null = null;
|
|
$: {
|
|
// Reset trim on tab change OR when data range changes
|
|
if (activeTab !== lastResetTab || trimMin < dataMin || trimMax > dataMax) {
|
|
trimMin = dataMin;
|
|
trimMax = dataMax;
|
|
lastResetTab = activeTab;
|
|
}
|
|
}
|
|
|
|
$: step = (dataMax - dataMin) / 200 || 1;
|
|
|
|
// Percentage positions for the active-range highlight bar
|
|
$: span = dataMax - dataMin || 1;
|
|
$: leftPct = ((trimMin - dataMin) / span) * 100;
|
|
$: rightPct = ((dataMax - trimMax) / span) * 100;
|
|
|
|
// Pre-filtered data + explicit evenly-spaced thresholds anchored to [trimMin, trimMax].
|
|
// d3's count-based thresholds snap to "nice" values and produce the wrong bin count
|
|
// when the range is narrow — explicit thresholds give exactly `bins` bins always.
|
|
$: histData = data.filter(d => {
|
|
const v = (d as any)[yKey];
|
|
return v != null && v >= trimMin && v <= trimMax;
|
|
});
|
|
$: histThresholds = Array.from(
|
|
{ length: bins - 1 },
|
|
(_, i) => trimMin + (i + 1) * (trimMax - trimMin) / bins,
|
|
);
|
|
|
|
// ── Zone alignment ───────────────────────────────────────────────────────
|
|
let alignZones = false;
|
|
$: canAlignZones = chartType === 'histogram' && !!(
|
|
activeTab === 'hr' ? athlete?.hr_zones?.length :
|
|
activeTab === 'power' ? athlete?.power_zones?.length :
|
|
false
|
|
);
|
|
// Reset when switching away from a zone-capable metric or leaving histogram
|
|
$: if (!canAlignZones) alignZones = false;
|
|
|
|
// ── Slope coloring ───────────────────────────────────────────────────────
|
|
function slopeColor(pct: number): string {
|
|
if (pct < -1) return '#60a5fa'; // descent
|
|
if (pct < 2) return '#4ade80'; // flat
|
|
if (pct < 5) return '#facc15'; // easy
|
|
if (pct < 8) return '#f97316'; // moderate
|
|
if (pct < 12) return '#ef4444'; // steep
|
|
return '#a855f7'; // wall
|
|
}
|
|
|
|
// Distance-weighted sliding-window slope smoothing (400 m window).
|
|
// O(n) via two advancing pointers. Matches SLOPE_COLORING.md spec.
|
|
function computeSmoothedSlopes(pts: { d: number; e: number }[], windowM = 400): number[] {
|
|
if (pts.length < 2) return pts.map(() => 0);
|
|
const half = windowM / 2;
|
|
const segs: { d: number; slope: number; len: number }[] = [];
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const len = pts[i + 1].d - pts[i].d;
|
|
segs.push({ d: (pts[i].d + pts[i + 1].d) / 2, slope: len > 0.5 ? (pts[i + 1].e - pts[i].e) / len * 100 : 0, len });
|
|
}
|
|
const slopes = new Array<number>(pts.length);
|
|
let lo = 0, hi = 0, sumW = 0, sumS = 0;
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const center = pts[i].d;
|
|
while (hi < segs.length && segs[hi].d <= center + half) { sumW += segs[hi].len; sumS += segs[hi].slope * segs[hi].len; hi++; }
|
|
while (lo < hi && segs[lo].d < center - half) { sumW -= segs[lo].len; sumS -= segs[lo].slope * segs[lo].len; lo++; }
|
|
slopes[i] = sumW > 0.5 ? sumS / sumW : 0;
|
|
}
|
|
return slopes;
|
|
}
|
|
|
|
$: slopeData = (() => {
|
|
if (!dist_km) return null;
|
|
const raw = timeseries.elevation_m;
|
|
const pts = dist_km.map((d, i) => ({ d: d * 1000, e: raw[i] ?? 0 }));
|
|
const slopes = computeSmoothedSlopes(pts);
|
|
return data.map((pt, i) => ({ ...pt, slope: slopes[i] }));
|
|
})();
|
|
|
|
function injectSlopeGradient(svg: SVGElement, pts: { dist_km: number | null; slope: number }[], w: number) {
|
|
const totalKm = pts[pts.length - 1].dist_km ?? 0;
|
|
if (totalKm === 0) return;
|
|
const N = Math.min(pts.length - 1, 80);
|
|
const stops: string[] = [];
|
|
for (let s = 0; s <= N; s++) {
|
|
const target = (s / N) * totalKm;
|
|
let slope = 0;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const d0 = pts[i].dist_km ?? 0;
|
|
const d1 = pts[i + 1].dist_km ?? 0;
|
|
if (target >= d0 && target <= d1 + 1e-9) {
|
|
const span = d1 - d0;
|
|
slope = span > 1e-9
|
|
? pts[i].slope + ((target - d0) / span) * (pts[i + 1].slope - pts[i].slope)
|
|
: pts[i].slope;
|
|
break;
|
|
}
|
|
}
|
|
stops.push(`<stop offset="${((s / N) * 100).toFixed(2)}%" stop-color="${slopeColor(slope)}" />`);
|
|
}
|
|
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
defs.innerHTML = `<linearGradient id="slope-grad" gradientUnits="userSpaceOnUse" x1="48" y1="0" x2="${w - 20}" y2="0">${stops.join('')}</linearGradient>`;
|
|
svg.insertBefore(defs, svg.firstChild);
|
|
const areaG = svg.querySelector('[aria-label="area"]') as SVGElement | null;
|
|
if (areaG) { areaG.setAttribute('fill', 'url(#slope-grad)'); areaG.setAttribute('fill-opacity', '0.45'); }
|
|
const lineG = svg.querySelector('[aria-label="line"]') as SVGElement | null;
|
|
if (lineG) lineG.setAttribute('stroke', 'url(#slope-grad)');
|
|
}
|
|
|
|
// ── 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
|
|
};
|
|
}
|
|
|
|
// ── Rolling mean (display only — raw data used for stats/histogram) ────
|
|
function rollingMean(vals: (number | null)[], halfWin: number): (number | null)[] {
|
|
return vals.map((_, i) => {
|
|
const lo = Math.max(0, i - halfWin);
|
|
const hi = Math.min(vals.length - 1, i + halfWin);
|
|
let sum = 0, count = 0;
|
|
for (let j = lo; j <= hi; j++) {
|
|
const v = vals[j];
|
|
if (v != null) { sum += v; count++; }
|
|
}
|
|
return count > 0 ? sum / count : null;
|
|
});
|
|
}
|
|
|
|
// ── Reference lines (avg, P20, P80) — speed, cadence, power, hr ────────
|
|
function refStats(tab: Tab, vals: number[]) {
|
|
if (tab !== 'speed' && tab !== 'cadence' && tab !== 'power' && tab !== 'hr') return null;
|
|
const moving = vals.filter(v => v > 0);
|
|
if (moving.length < 5) return null;
|
|
const avg = moving.reduce((a, b) => a + b, 0) / moving.length;
|
|
const sorted = [...moving].sort((a, b) => a - b);
|
|
const q = (p: number) => sorted[Math.max(0, Math.floor((sorted.length - 1) * p))];
|
|
return { avg, p20: q(0.2), p80: q(0.8) };
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────────
|
|
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; smoothMode;
|
|
renderChart();
|
|
}
|
|
|
|
function renderChart() {
|
|
if (!chartEl) return;
|
|
chart?.remove();
|
|
|
|
const w = chartEl.clientWidth || 800;
|
|
const h = 220;
|
|
const { color, yLabel, yKey } = tabMeta[activeTab];
|
|
|
|
const tabEnabled =
|
|
activeTab === 'elevation' ? hasElevation :
|
|
activeTab === 'slope' ? hasSlope :
|
|
activeTab === 'speed' ? hasSpeed :
|
|
activeTab === 'hr' ? hasHR :
|
|
activeTab === 'cadence' ? hasCadence :
|
|
hasPower;
|
|
if (!tabEnabled) return;
|
|
|
|
chart = (chartType === 'histogram' && activeTab !== 'slope')
|
|
? renderHistogram(w, h, yKey, yLabel, color)
|
|
: renderLine(w, h, yKey, yLabel, color);
|
|
|
|
if (chartType === 'line' || activeTab === 'slope') {
|
|
chart.addEventListener('input', () => {
|
|
const pt = (chart as any)?.value;
|
|
hoveredIdx = pt ? timeseries.t.findIndex(t => t === pt.t) : null;
|
|
});
|
|
}
|
|
|
|
chartEl.appendChild(chart);
|
|
}
|
|
|
|
function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) {
|
|
const isSlope = activeTab === 'slope';
|
|
const x = (xMode === 'distance' || isSlope) ? 'dist_km' : 't';
|
|
const tc = getThemeColors();
|
|
const marks: any[] = [];
|
|
|
|
// monotone-x requires strictly increasing x. In time mode t is always
|
|
// strictly increasing. In distance mode, stopped segments produce many
|
|
// consecutive points with identical dist_km, which causes NaN Bézier
|
|
// control points and visual artifacts — use linear instead.
|
|
const curve = (xMode === 'distance' || isSlope) ? 'linear' : 'monotone-x';
|
|
|
|
// Slope tab uses pre-computed slopeData (elevation already smoothed, slope attached).
|
|
// Other tabs apply rolling mean here for visual rendering only.
|
|
const halfWin = SMOOTH_HALF[smoothMode];
|
|
const lineData = isSlope && slopeData
|
|
? slopeData
|
|
: (halfWin > 0
|
|
? (() => {
|
|
const s = rollingMean(data.map(d => (d as any)[yKey] as number | null), halfWin);
|
|
return data.map((d, i) => ({ ...d, [yKey]: s[i] }));
|
|
})()
|
|
: data);
|
|
|
|
if (activeTab === 'cadence') {
|
|
marks.push(Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }));
|
|
} else {
|
|
marks.push(
|
|
Plot.areaY(lineData, { x, y: yKey, fill: color, fillOpacity: isSlope ? 0.45 : 0.15, curve }),
|
|
Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }),
|
|
);
|
|
}
|
|
|
|
// ── Reference lines (avg, P20, P80) ─────────────────────────────────────
|
|
const ref = refStats(activeTab, metricValues);
|
|
if (ref) {
|
|
const xEnd = (data[data.length - 1] as any)?.[x];
|
|
marks.push(
|
|
Plot.ruleY([ref.avg], { stroke: color, strokeWidth: 1.5, strokeDasharray: '6,4', strokeOpacity: 0.65 }),
|
|
Plot.ruleY([ref.p20, ref.p80], { stroke: tc.axis, strokeWidth: 1, strokeDasharray: '3,4', strokeOpacity: 0.55 }),
|
|
Plot.text(
|
|
[
|
|
{ xv: xEnd, yv: ref.avg, label: `avg ${Math.round(ref.avg)}` },
|
|
{ xv: xEnd, yv: ref.p20, label: `P20 ${Math.round(ref.p20)}` },
|
|
{ xv: xEnd, yv: ref.p80, label: `P80 ${Math.round(ref.p80)}` },
|
|
],
|
|
{ x: 'xv', y: 'yv', text: 'label', textAnchor: 'end', dx: -4, dy: -6,
|
|
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3, fontSize: 11 },
|
|
),
|
|
);
|
|
}
|
|
|
|
marks.push(
|
|
Plot.ruleX(lineData, Plot.pointerX({ x, stroke: tc.rule, strokeWidth: 1, strokeDasharray: '4,4' })),
|
|
Plot.dot(lineData, Plot.pointerX({
|
|
x, y: yKey, r: 4,
|
|
fill: isSlope ? (d: any) => slopeColor(d.slope ?? 0) : color,
|
|
stroke: tc.tooltipBg, strokeWidth: 1.5,
|
|
})),
|
|
);
|
|
|
|
if (isSlope) {
|
|
const nearTop = (d: any) => {
|
|
const range = lineDomainMax - lineDomainMin;
|
|
return range > 0 && (d[yKey] - lineDomainMin) / range > 0.85;
|
|
};
|
|
marks.push(
|
|
Plot.text(lineData, Plot.pointerX({
|
|
x, y: yKey,
|
|
text: (d: any) => d.slope != null ? `${d.slope > 0.05 ? '+' : ''}${(+d.slope).toFixed(1)}%` : '',
|
|
dy: (d: any) => nearTop(d) ? 22 : -22,
|
|
fill: (d: any) => slopeColor(d.slope ?? 0),
|
|
stroke: tc.tooltipBg, strokeWidth: 3,
|
|
fontSize: 11, fontWeight: '700',
|
|
})),
|
|
Plot.text(lineData, Plot.pointerX({
|
|
x, y: yKey,
|
|
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}m` : '',
|
|
dy: (d: any) => nearTop(d) ? 10 : -10,
|
|
fill: tc.axis, stroke: tc.tooltipBg, strokeWidth: 3,
|
|
fontSize: 10,
|
|
})),
|
|
);
|
|
} else {
|
|
marks.push(
|
|
Plot.text(lineData, Plot.pointerX({
|
|
x, y: yKey,
|
|
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
|
|
dy: -12,
|
|
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
|
|
fontSize: 11, fontWeight: '600',
|
|
})),
|
|
);
|
|
}
|
|
|
|
const xTickFormat = (xMode === 'distance' || isSlope)
|
|
? (v: number) => `${v.toFixed(1)} km`
|
|
: (t: number) => {
|
|
const h = Math.floor(t / 3600);
|
|
const m = Math.floor((t % 3600) / 60);
|
|
return h > 0 ? `${h}h${m.toString().padStart(2, '0')}` : `${m}m`;
|
|
};
|
|
|
|
const svg = Plot.plot({
|
|
width: w, height: h, marginLeft: 48, marginBottom: 32,
|
|
style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
|
|
x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 },
|
|
y: { label: yLabel, grid: true, tickCount: 4, domain: [lineDomainMin, lineDomainMax] },
|
|
marks,
|
|
});
|
|
|
|
if (isSlope && slopeData) injectSlopeGradient(svg, slopeData, w);
|
|
return svg;
|
|
}
|
|
|
|
function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) {
|
|
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) {
|
|
// Cap the top zone's hi at the actual data max so sentinel values like
|
|
// 999 bpm or 9999 W don't stretch the x-axis into empty space.
|
|
const dataMax = Math.max(...data.map((d: any) => d[yKey]).filter((v: any) => v != null));
|
|
const clampedZones = rawZones.map((z, i) =>
|
|
i === rawZones.length - 1 ? [z[0], Math.min(z[1], dataMax * 1.05)] : z
|
|
);
|
|
|
|
const zoneBars = clampedZones.map((z, i) => ({
|
|
lo: z[0], hi: z[1],
|
|
// Count directly from full data — trim sliders don't apply in zone mode
|
|
count: data.filter((d: any) => { const v = d[yKey]; return v != null && v >= rawZones[i][0] && v < rawZones[i][1]; }).length,
|
|
color: zoneColors[i] ?? zoneColors[zoneColors.length - 1],
|
|
label: `Z${i + 1}`,
|
|
}));
|
|
|
|
return Plot.plot({
|
|
width: w, height: h, marginLeft: 48, marginBottom: 32,
|
|
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: [
|
|
Plot.rect(zoneBars, {
|
|
x1: 'lo', x2: 'hi', y1: 0, y2: 'count',
|
|
fill: 'color', fillOpacity: 0.75,
|
|
}),
|
|
Plot.text(zoneBars, {
|
|
x: (d: any) => (d.lo + d.hi) / 2,
|
|
y: 'count',
|
|
text: 'label',
|
|
fill: 'color',
|
|
fontSize: 10, fontWeight: '600',
|
|
dy: -8,
|
|
}),
|
|
Plot.ruleY([0], { stroke: tc.ruleY }),
|
|
],
|
|
});
|
|
}
|
|
|
|
// ── Normal histogram with optional zone overlays ─────────────────────────
|
|
const marks: any[] = [
|
|
Plot.rectY(histData, Plot.binX(
|
|
{ y: 'count' },
|
|
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
|
|
)),
|
|
Plot.ruleY([0], { stroke: tc.ruleY }),
|
|
];
|
|
|
|
if (rawZones?.length) {
|
|
const boundaries = rawZones.slice(0, -1).map((z, i) => ({
|
|
x: z[1],
|
|
color: zoneColors[i + 1] ?? zoneColors[zoneColors.length - 1],
|
|
})).filter(b => b.x > trimMin && b.x < trimMax);
|
|
|
|
const labels = rawZones.map((z, i) => ({
|
|
mid: (Math.max(z[0], trimMin) + Math.min(z[1], trimMax)) / 2,
|
|
label: `Z${i + 1}`,
|
|
color: zoneColors[i] ?? zoneColors[zoneColors.length - 1],
|
|
visible: z[1] > trimMin && z[0] < trimMax,
|
|
})).filter(l => l.visible && l.mid >= trimMin && l.mid <= trimMax);
|
|
|
|
marks.push(
|
|
Plot.ruleX(boundaries, {
|
|
x: 'x',
|
|
stroke: (d: any) => d.color,
|
|
strokeWidth: 1, strokeOpacity: 0.5, strokeDasharray: '4,3',
|
|
}),
|
|
Plot.text(labels, {
|
|
x: 'mid', text: 'label', fill: (d: any) => d.color,
|
|
fontSize: 9, fontWeight: '600', frameAnchor: 'top', dy: 6,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return Plot.plot({
|
|
width: w, height: h, marginLeft: 48, marginBottom: 32,
|
|
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,
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<!-- Metric tabs + chart controls -->
|
|
<div class="flex items-center gap-1 mb-3 flex-wrap">
|
|
{#each Object.entries(tabLabels) as [tab, label]}
|
|
{@const enabled =
|
|
tab === 'elevation' ? hasElevation :
|
|
tab === 'slope' ? hasSlope :
|
|
tab === 'speed' ? hasSpeed :
|
|
tab === 'hr' ? hasHR :
|
|
tab === 'cadence' ? hasCadence :
|
|
hasPower}
|
|
<button
|
|
class="px-3 py-1.5 rounded-md text-sm transition-colors"
|
|
class:opacity-30={!enabled}
|
|
class:cursor-not-allowed={!enabled}
|
|
class:bg-zinc-800={activeTab === tab}
|
|
class:text-white={activeTab === tab}
|
|
class:text-zinc-500={activeTab !== tab}
|
|
class:hover:text-zinc-300={activeTab !== tab && enabled}
|
|
disabled={!enabled}
|
|
on:click={() => { if (enabled) activeTab = tab as Tab; }}
|
|
>
|
|
{label}
|
|
</button>
|
|
{/each}
|
|
|
|
<div class="flex-1"></div>
|
|
|
|
<div class="flex items-center gap-3 text-xs text-zinc-500">
|
|
{#if hasDistance && chartType === 'line' && activeTab !== 'slope'}
|
|
<div class="flex items-center gap-1">
|
|
<span class="mr-0.5">X:</span>
|
|
{#each (['time', 'distance'] as XMode[]) as mode}
|
|
<button
|
|
class="px-2 py-1 rounded transition-colors"
|
|
class:bg-zinc-800={xMode === mode}
|
|
class:text-white={xMode === mode}
|
|
class:hover:text-zinc-300={xMode !== mode}
|
|
on:click={() => xMode = mode}
|
|
>{mode === 'time' ? 'Time' : 'Dist'}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if chartType === 'line' && activeTab !== 'slope'}
|
|
<div class="flex items-center gap-1">
|
|
{#each (['raw', '10s', '20s'] as SmoothMode[]) as sm}
|
|
<button
|
|
class="px-2 py-1 rounded transition-colors"
|
|
class:bg-zinc-800={smoothMode === sm}
|
|
class:text-white={smoothMode === sm}
|
|
class:hover:text-zinc-300={smoothMode !== sm}
|
|
on:click={() => smoothMode = sm}
|
|
title="Smoothing window"
|
|
>{sm === 'raw' ? '~ Raw' : sm}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if activeTab !== 'slope'}
|
|
<div class="flex items-center gap-1">
|
|
{#each (['line', 'histogram'] as ChartType[]) as type}
|
|
<button
|
|
class="px-2 py-1 rounded transition-colors"
|
|
class:bg-zinc-800={chartType === type}
|
|
class:text-white={chartType === type}
|
|
class:hover:text-zinc-300={chartType !== type}
|
|
on:click={() => chartType = type}
|
|
title={type === 'line' ? 'Time series' : 'Distribution'}
|
|
>{type === 'line' ? '↗ Line' : '▭ Hist'}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div bind:this={chartEl} class="w-full overflow-hidden" style="min-height:220px"></div>
|
|
|
|
<!-- Histogram controls (range + bins) -->
|
|
{#if chartType === 'histogram'}
|
|
<div class="mt-3 flex flex-col gap-2 text-xs text-zinc-400">
|
|
|
|
<!-- Bins mode toggle — only shown when zones are available -->
|
|
{#if canAlignZones}
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-zinc-500 mr-1">Bins:</span>
|
|
{#each ([false, true] as boolean[]) as zoneMode}
|
|
<button
|
|
class="px-2 py-1 rounded transition-colors"
|
|
class:bg-zinc-800={alignZones === zoneMode}
|
|
class:text-white={alignZones === zoneMode}
|
|
class:text-zinc-500={alignZones !== zoneMode}
|
|
class:hover:text-zinc-300={alignZones !== zoneMode}
|
|
on:click={() => alignZones = zoneMode}
|
|
>{zoneMode ? 'Zones' : 'Custom'}</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !alignZones}
|
|
<!-- Dual range slider -->
|
|
<div class="flex items-center gap-3">
|
|
<span class="w-8 text-right shrink-0">{Math.round(trimMin)}</span>
|
|
<div class="relative flex-1" style="height:20px">
|
|
<!-- Background track -->
|
|
<div class="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-700 rounded-full pointer-events-none"></div>
|
|
<!-- Active range fill -->
|
|
<div
|
|
class="absolute top-1/2 -translate-y-1/2 h-1 bg-zinc-400 rounded-full pointer-events-none"
|
|
style="left:{leftPct}%; right:{rightPct}%"
|
|
></div>
|
|
<!-- Min handle -->
|
|
<input
|
|
type="range"
|
|
min={dataMin} max={dataMax} {step}
|
|
value={trimMin}
|
|
on:input={(e) => { const v = +e.currentTarget.value; trimMin = v < trimMax - step ? v : trimMax - step; }}
|
|
class="range-thumb"
|
|
/>
|
|
<!-- Max handle -->
|
|
<input
|
|
type="range"
|
|
min={dataMin} max={dataMax} {step}
|
|
value={trimMax}
|
|
on:input={(e) => { const v = +e.currentTarget.value; trimMax = v > trimMin + step ? v : trimMin + step; }}
|
|
class="range-thumb"
|
|
/>
|
|
</div>
|
|
<span class="w-8 shrink-0">{Math.round(trimMax)}</span>
|
|
</div>
|
|
|
|
<!-- Bins slider -->
|
|
<div class="flex items-center gap-3">
|
|
<span class="w-8 text-right shrink-0 text-zinc-500">Bins</span>
|
|
<input
|
|
type="range" min="5" max="20" step="1"
|
|
bind:value={bins}
|
|
class="flex-1 h-1 accent-zinc-400 cursor-pointer"
|
|
/>
|
|
<span class="w-8 shrink-0">{bins}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.range-thumb {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
margin: 0;
|
|
padding: 0;
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
background: transparent;
|
|
pointer-events: none;
|
|
}
|
|
.range-thumb::-webkit-slider-runnable-track {
|
|
background: transparent;
|
|
height: 4px;
|
|
}
|
|
.range-thumb::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
background: #e4e4e7;
|
|
border: 2px solid #52525b;
|
|
cursor: pointer;
|
|
pointer-events: all;
|
|
margin-top: -5px; /* center on 4px track */
|
|
}
|
|
.range-thumb::-moz-range-track {
|
|
background: transparent;
|
|
height: 4px;
|
|
}
|
|
.range-thumb::-moz-range-thumb {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
background: #e4e4e7;
|
|
border: 2px solid #52525b;
|
|
cursor: pointer;
|
|
pointer-events: all;
|
|
}
|
|
</style>
|