map now working

This commit is contained in:
Davide Scaini
2026-03-28 19:34:22 +01:00
parent 5d58126d2f
commit 3441079913
18 changed files with 1489 additions and 10 deletions
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Timeseries } from '../lib/types';
export let timeseries: Timeseries;
// Linked hover: emit/receive index into timeseries arrays
export let hoveredIdx: number | null = null;
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence';
let activeTab: Tab = 'elevation';
let chartEl: HTMLDivElement;
let Plot: any;
let chart: SVGElement | null = null;
// Pre-build data array once
$: data = timeseries.t.map((t, i) => ({
t,
elevation: timeseries.elevation_m[i],
speed: timeseries.speed_kmh[i],
hr: timeseries.hr_bpm[i],
cadence: timeseries.cadence_rpm[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);
const tabLabels: Record<Tab, string> = {
elevation: 'Elevation',
speed: 'Speed',
hr: 'Heart Rate',
cadence: 'Cadence',
};
onMount(async () => {
Plot = await import('@observablehq/plot');
renderChart();
});
$: if (Plot && chartEl) {
activeTab; // reactive dependency
renderChart();
}
function renderChart() {
if (!Plot || !chartEl) return;
chart?.remove();
const w = chartEl.clientWidth || 800;
const h = 220;
const marks: any[] = [];
let yLabel = '';
let yKey = '';
let color = '#00c8ff';
if (activeTab === 'elevation' && hasElevation) {
yKey = 'elevation'; yLabel = 'Elevation (m)'; color = '#00c8ff';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
} else if (activeTab === 'speed' && hasSpeed) {
yKey = 'speed'; yLabel = 'Speed (km/h)'; color = '#ff6b35';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
} else if (activeTab === 'hr' && hasHR) {
yKey = 'hr'; yLabel = 'Heart Rate (bpm)'; color = '#f87171';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
} else if (activeTab === 'cadence' && hasCadence) {
yKey = 'cadence'; yLabel = 'Cadence (rpm)'; color = '#a78bfa';
marks.push(
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
}
if (!marks.length) return;
// Hover crosshair
marks.push(
Plot.ruleX(data, Plot.pointerX({
x: 't',
stroke: 'rgba(255,255,255,0.3)',
strokeWidth: 1,
strokeDasharray: '4,4',
})),
Plot.dot(data, Plot.pointerX({
x: 't', y: yKey,
r: 4, fill: color, stroke: 'white', strokeWidth: 1.5,
})),
Plot.text(data, Plot.pointerX({
x: 't', y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
})),
);
chart = Plot.plot({
width: w,
height: h,
marginLeft: 48,
marginBottom: 32,
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
x: {
label: null,
tickFormat: (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`;
},
grid: false,
ticks: 6,
},
y: { label: yLabel, grid: true, tickCount: 4 },
marks,
});
// Attach pointer listener to emit hover index
chart.addEventListener('input', () => {
const pt = (chart as any)?.value;
if (pt) {
hoveredIdx = timeseries.t.findIndex(t => t === pt.t);
} else {
hoveredIdx = null;
}
});
chartEl.appendChild(chart);
}
</script>
<!-- Tab bar -->
<div class="flex gap-1 mb-3">
{#each Object.entries(tabLabels) as [tab, label]}
{@const enabled =
tab === 'elevation' ? hasElevation :
tab === 'speed' ? hasSpeed :
tab === 'hr' ? hasHR :
hasCadence}
<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>
<div bind:this={chartEl} class="w-full overflow-hidden" />