/* Charts: small SVG components — sparkline, ring, bar, multi-line stream */ const SparkLine = ({ data, width = 120, height = 32, color = "var(--accent)", fill = true, dots = false, fluid = false }) => { if (!data || data.length < 2) return null; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; // Coordinate space stays at `width`; cuando fluid=true el SVG se estira al // 100% del contenedor vía CSS (preserveAspectRatio="none"). Eso evita el // medio-sparkline en cards que crecen con el viewport. const step = width / (data.length - 1); const points = data.map((d, i) => [i * step, height - ((d - min) / range) * (height - 4) - 2]); const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" "); const area = `${path} L${width} ${height} L0 ${height} Z`; const svgProps = fluid ? { width: "100%", height, viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", style: { display: "block" } } : { width, height, viewBox: `0 0 ${width} ${height}`, style: { overflow: "visible" } }; return ( {fill && } {dots && points.map(([x, y], i) => ( ))} ); }; const Ring = ({ value = 0, max = 100, size = 64, stroke = 6, color = "var(--accent)", track = "var(--line)", label, sublabel }) => { const r = (size - stroke) / 2; const c = 2 * Math.PI * r; const pct = Math.min(1, Math.max(0, value / max)); return (
{(label || sublabel) && (
{label &&
{label}
} {sublabel &&
{sublabel}
}
)}
); }; const Bars = ({ data, width = 280, height = 60, color = "var(--accent)", labels }) => { if (!data) return null; const max = Math.max(...data, 1); const gap = 3; const bw = (width - gap * (data.length - 1)) / data.length; return ( {data.map((d, i) => { const h = (d / max) * height; return ( {labels && ( {labels[i]} )} ); })} ); }; /* Larger time-series area chart with grid */ const AreaChart = ({ data, width = 600, height = 200, color = "var(--accent)", yLabel, xLabels = [], yMin, yMax, secondary }) => { if (!data || data.length < 2) return null; const padL = 32, padR = 8, padT = 12, padB = 24; const w = width - padL - padR; const h = height - padT - padB; const min = yMin !== undefined ? yMin : Math.min(...data); const max = yMax !== undefined ? yMax : Math.max(...data); const range = max - min || 1; const step = w / (data.length - 1); const pts = data.map((d, i) => [padL + i * step, padT + h - ((d - min) / range) * h]); const path = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" "); const area = `${path} L${padL + w} ${padT + h} L${padL} ${padT + h} Z`; const yTicks = 4; const ticks = Array.from({ length: yTicks + 1 }, (_, i) => min + (range * i) / yTicks); return ( {ticks.map((t, i) => { const y = padT + h - (i / yTicks) * h; return ( {Math.round(t)} ); })} {secondary && (() => { const sMin = Math.min(...secondary); const sMax = Math.max(...secondary); const sR = sMax - sMin || 1; const sPts = secondary.map((d, i) => [padL + i * step, padT + h - ((d - sMin) / sR) * h]); const sPath = sPts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" "); return ; })()} {pts.map(([x, y], i) => ( ))} {xLabels.map((l, i) => { const idx = Math.floor((i * (data.length - 1)) / (xLabels.length - 1)); const x = padL + idx * step; return ( {l} ); })} ); }; /* Multi-line stream (HR, pace, elevation). * * Cada serie acepta `{ data, color, fill?, label?, axis?, format? }`: * - `axis`: "left" | "right" — decide a qué costado va la escala (min/max). * - `format(v)`: opcional — cómo formatear el min/max para esa serie. * (ej. pace negativo se invierte para mostrar pace real). * - `label`: corto, mostrado debajo del valor (ej. "FC", "Pace"). * * Si una serie no declara `axis`, no aparece etiquetada (compat hacia atrás). */ const StreamChart = ({ series, width = 800, height = 220, xMax = 100 }) => { const padL = 56, padR = 56, padT = 14, padB = 22; const w = width - padL - padR; const h = height - padT - padB; const axisSeries = (side) => series.filter((s) => s.axis === side); const fmtNum = (v) => { if (v == null || !isFinite(v)) return "—"; if (Math.abs(v) >= 100) return Math.round(v).toString(); if (Math.abs(v) >= 10) return v.toFixed(1); return v.toFixed(2); }; const renderAxis = (side) => { const ss = axisSeries(side); if (!ss.length) return null; const x = side === "left" ? padL - 8 : padL + w + 8; const anchor = side === "left" ? "end" : "start"; return ss.map((s, idx) => { const min = Math.min(...s.data); const max = Math.max(...s.data); const fmt = s.format || fmtNum; // Bloque vertical por serie: max arriba, label, min abajo. const blockTop = padT + idx * 30; return ( {fmt(max)} {s.label && ( {s.label} )} {fmt(min)} ); }); }; return ( {[0, 0.25, 0.5, 0.75, 1].map((p, i) => ( ))} {series.map((s, sIdx) => { if (!s.data || s.data.length < 2) return null; const min = Math.min(...s.data); const max = Math.max(...s.data); const range = max - min || 1; const step = w / (s.data.length - 1); const pts = s.data.map((d, i) => [padL + i * step, padT + h - ((d - min) / range) * h]); const path = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" "); return ( {s.fill && } ); })} {renderAxis("left")} {renderAxis("right")} {/* X axis km labels */} {[0, 0.25, 0.5, 0.75, 1].map((p, i) => ( {(p * xMax).toFixed(1)}{p===1?" km":""} ))} ); }; /* Heatmap-ish weekly grid */ const WeekGrid = ({ data, max, color = "var(--accent)", labels = ["L","M","X","J","V","S","D"] }) => { return (
{data.map((d, i) => { const o = Math.max(0.08, d / max); return (
{labels[i]}
{d}
); })}
); }; Object.assign(window, { SparkLine, Ring, Bars, AreaChart, StreamChart, WeekGrid });