Files
bincio-activity/site/src/components/ActivityCharts.svelte
T

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>