map now working
This commit is contained in:
@@ -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" />
|
||||
Reference in New Issue
Block a user