Spaces:
Running
Running
Download src/lib/LineChart.svelte from mfuntowicz/hfinfer: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/mfuntowicz/hfinfer/resolve/main/src/lib/LineChart.svelte
- Command line
-
hf download hf://spaces/mfuntowicz/hfinfer/src/lib/LineChart.svelte
-
curl -L -o LineChart.svelte https://huggingface.co/spaces/mfuntowicz/hfinfer/resolve/main/src/lib/LineChart.svelte
15.2 kB
| <script lang="ts"> | |
| import type { Series, SeriesPoint } from './data' | |
| import { formatMetric } from './types' | |
| import { colorFor, formatTick, yDomain } from './viz' | |
| interface Props { | |
| metric: string | |
| label: string | |
| series: Series[] | |
| lowerIsBetter?: boolean | |
| } | |
| let { metric, label, series, lowerIsBetter = false }: Props = $props() | |
| const W = 460 | |
| const H = 180 | |
| // Extra right padding makes room for end-of-line value labels. | |
| const PAD = { top: 10, right: 58, bottom: 24, left: 52 } | |
| let allPoints = $derived(series.flatMap((s) => s.points)) | |
| let tMin = $derived(Math.min(...allPoints.map((p) => p.t))) | |
| let tMax = $derived(Math.max(...allPoints.map((p) => p.t))) | |
| // All points on a single date: no line to draw, one centered column of dots. | |
| let singleDate = $derived(tMin === tMax) | |
| // Padded nice domain around the data (not zero-anchored) so small | |
| // regressions are visible; see viz.ts for the zero-inclusion rule. | |
| let domain = $derived(yDomain(allPoints.map((p) => p.y))) | |
| function sx(t: number): number { | |
| return PAD.left + (singleDate ? 0.5 : (t - tMin) / (tMax - tMin)) * (W - PAD.left - PAD.right) | |
| } | |
| function sy(v: number): number { | |
| return PAD.top + (1 - (v - domain.min) / (domain.max - domain.min || 1)) * (H - PAD.top - PAD.bottom) | |
| } | |
| // At most 6 date labels along the x axis to avoid overlap. | |
| let xTicks = $derived.by(() => { | |
| const ts = [...new Set(allPoints.map((p) => p.t))].sort((a, b) => a - b) | |
| const MAX = 6 | |
| if (ts.length <= MAX) return ts | |
| const step = (ts.length - 1) / (MAX - 1) | |
| return Array.from({ length: MAX }, (_, i) => ts[Math.round(i * step)]) | |
| }) | |
| function path(pts: SeriesPoint[]): string { | |
| return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.t).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' ') | |
| } | |
| // Latest value at the end of each line (leaderboard edge); labels are | |
| // nudged apart vertically when they collide. | |
| let endLabels = $derived.by(() => { | |
| const items = series | |
| .filter((s) => s.points.length > 0) | |
| .map((s) => { | |
| const p = s.points[s.points.length - 1] | |
| return { | |
| name: s.name, | |
| color: colorFor(s.name), | |
| text: formatMetric(metric, p.y), | |
| x: sx(p.t) + 5, | |
| y: sy(p.y), | |
| ly: sy(p.y), | |
| } | |
| }) | |
| .sort((a, b) => a.y - b.y) | |
| const GAP = 9 | |
| const lo = PAD.top + 4 | |
| const hi = H - PAD.bottom - 2 | |
| if (items.length > 0) items[0].ly = Math.max(items[0].ly, lo) | |
| for (let i = 1; i < items.length; i++) items[i].ly = Math.max(items[i].ly, items[i - 1].ly + GAP) | |
| const overflow = items.length > 0 ? items[items.length - 1].ly - hi : 0 | |
| if (overflow > 0) { | |
| for (const it of items) it.ly = Math.max(lo, it.ly - overflow) | |
| for (let i = items.length - 2; i >= 0; i--) items[i].ly = Math.min(items[i].ly, items[i + 1].ly - GAP) | |
| } | |
| return items | |
| }) | |
| // Hover-to-highlight: hovering a line or legend entry dims the other series. | |
| let highlight = $state<string | null>(null) | |
| // HTML tooltip shown over the chart on point hover (<title> kept as fallback). | |
| interface Tip { | |
| x: number | |
| y: number | |
| name: string | |
| date: string | |
| value: string | |
| deltaText: string | null | |
| deltaGood: boolean | |
| deltaBad: boolean | |
| commits: string | null | |
| } | |
| let tip = $state<Tip | null>(null) | |
| let plotEl = $state<HTMLDivElement | undefined>() | |
| function showTip(e: MouseEvent, s: Series, p: SeriesPoint, idx: number): void { | |
| const el = plotEl | |
| if (!el) return | |
| const r = el.getBoundingClientRect() | |
| const prev = idx > 0 ? s.points[idx - 1] : null | |
| const d = prev && prev.y !== 0 ? ((p.y - prev.y) / prev.y) * 100 : null | |
| tip = { | |
| x: Math.min(Math.max(e.clientX - r.left, 85), r.width - 85), | |
| y: e.clientY - r.top, | |
| name: s.name, | |
| date: p.date, | |
| value: formatMetric(metric, p.y), | |
| deltaText: | |
| d === null | |
| ? prev | |
| ? 'first tracked value' | |
| : null | |
| : `${d > 0 ? '▲' : d < 0 ? '▼' : '•'} ${Math.abs(d).toFixed(1)}% vs ${prev ? fmtDate(prev.t) : 'prev'}`, | |
| deltaGood: d !== null && d !== 0 && (lowerIsBetter ? d < 0 : d > 0), | |
| deltaBad: d !== null && d !== 0 && (lowerIsBetter ? d > 0 : d < 0), | |
| commits: p.commits && p.commits.length > 0 ? p.commits.join(', ') : null, | |
| } | |
| } | |
| // The series with the best latest value drives the KPI; its own previous | |
| // and first points give the short-term (Δ) and over-range deltas. | |
| let bestSeries = $derived( | |
| series | |
| .filter((s) => s.points.length > 0) | |
| .sort((a, b) => { | |
| const la = a.points[a.points.length - 1].y | |
| const lb = b.points[b.points.length - 1].y | |
| return lowerIsBetter ? la - lb : lb - la | |
| })[0] | |
| ) | |
| let latest = $derived(bestSeries?.points[bestSeries.points.length - 1]) | |
| let previous = $derived(bestSeries?.points[bestSeries.points.length - 2] ?? null) | |
| let first = $derived(bestSeries?.points[0]) | |
| let delta = $derived( | |
| latest && previous && previous.y !== 0 ? ((latest.y - previous.y) / previous.y) * 100 : null | |
| ) | |
| let rangeDelta = $derived( | |
| latest && first && first !== latest && first.y !== 0 ? ((latest.y - first.y) / first.y) * 100 : null | |
| ) | |
| let deltaGood = $derived(delta !== null && delta !== 0 && (lowerIsBetter ? delta < 0 : delta > 0)) | |
| let deltaBad = $derived(delta !== null && delta !== 0 && (lowerIsBetter ? delta > 0 : delta < 0)) | |
| let rangeGood = $derived(rangeDelta !== null && rangeDelta !== 0 && (lowerIsBetter ? rangeDelta < 0 : rangeDelta > 0)) | |
| let rangeBad = $derived(rangeDelta !== null && rangeDelta !== 0 && (lowerIsBetter ? rangeDelta > 0 : rangeDelta < 0)) | |
| const fmtDate = (t: number) => | |
| new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) | |
| </script> | |
| <div class="chart-card"> | |
| <div class="chart-head"> | |
| <h3>{label}</h3> | |
| {#if latest} | |
| <div class="kpi"> | |
| <span class="kpi-value">{formatMetric(metric, latest.y)}</span> | |
| {#if delta !== null} | |
| <span class="kpi-delta" class:good={deltaGood} class:bad={deltaBad} title="vs {previous?.date}"> | |
| {delta > 0 ? '▲' : delta < 0 ? '▼' : '•'} {Math.abs(delta).toFixed(1)}% | |
| </span> | |
| {/if} | |
| {#if rangeDelta !== null} | |
| <span class="kpi-range" class:good={rangeGood} class:bad={rangeBad} title="since {first?.date}"> | |
| {rangeDelta > 0 ? '▲' : rangeDelta < 0 ? '▼' : '•'} {Math.abs(rangeDelta).toFixed(1)}% over range | |
| </span> | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| {#if allPoints.length === 0} | |
| <div class="empty">No data for current filters</div> | |
| {:else} | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <div | |
| class="plot" | |
| bind:this={plotEl} | |
| onmouseleave={() => { | |
| tip = null | |
| highlight = null | |
| }} | |
| > | |
| <svg viewBox="0 0 {W} {H}" role="img" aria-label={label}> | |
| {#each domain.ticks as tick (tick)} | |
| <line class="grid" x1={PAD.left} x2={W - PAD.right} y1={sy(tick)} y2={sy(tick)} /> | |
| <text class="tick" x={PAD.left - 6} y={sy(tick) + 3} text-anchor="end"> | |
| {formatTick(metric, tick, domain.step)} | |
| </text> | |
| {/each} | |
| {#each xTicks as tick (tick)} | |
| <text class="tick" x={sx(tick)} y={H - 6} text-anchor="middle">{fmtDate(tick)}</text> | |
| {/each} | |
| {#each series as s (s.name)} | |
| {@const color = colorFor(s.name)} | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <g | |
| class="series" | |
| opacity={highlight !== null && highlight !== s.name ? 0.15 : 1} | |
| onmouseenter={() => (highlight = s.name)} | |
| onmouseleave={() => (highlight = null)} | |
| > | |
| {#if s.points.length > 1} | |
| <!-- transparent fat stroke = easy hover target for highlight --> | |
| <path class="hitline" d={path(s.points)} /> | |
| <path class="line" d={path(s.points)} stroke={color} /> | |
| {/if} | |
| {#each s.points as p, j (p.t)} | |
| <circle | |
| class="dot" | |
| cx={sx(p.t)} | |
| cy={sy(p.y)} | |
| r={j === s.points.length - 1 ? 4 : 2.8} | |
| fill={color} | |
| stroke={j === s.points.length - 1 ? 'var(--card)' : 'none'} | |
| stroke-width={j === s.points.length - 1 ? 1.5 : 0} | |
| /> | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <circle | |
| class="hit" | |
| cx={sx(p.t)} | |
| cy={sy(p.y)} | |
| r="8" | |
| fill="transparent" | |
| onmouseenter={(e) => showTip(e, s, p, j)} | |
| onmousemove={(e) => showTip(e, s, p, j)} | |
| onmouseleave={() => (tip = null)} | |
| > | |
| <title> | |
| {s.name} — {p.date}: {formatMetric(metric, p.y)}{p.commits ? ` @ ${p.commits.join(', ')}` : ''} | |
| </title> | |
| </circle> | |
| {/each} | |
| </g> | |
| {/each} | |
| {#each endLabels as el (el.name)} | |
| <text | |
| class="end-label" | |
| x={el.x} | |
| y={el.ly + 3} | |
| fill={el.color} | |
| opacity={highlight !== null && highlight !== el.name ? 0.15 : 1} | |
| > | |
| {el.text} | |
| </text> | |
| {/each} | |
| </svg> | |
| {#if tip} | |
| <div | |
| class="tip" | |
| class:below={tip.y < 80} | |
| style:left="{tip.x}px" | |
| style:top="{tip.y}px" | |
| role="tooltip" | |
| > | |
| <div class="tip-name" style="color:{colorFor(tip.name)}">{tip.name}</div> | |
| <div class="tip-row"> | |
| <span class="tip-date">{tip.date}</span> | |
| <strong>{tip.value}</strong> | |
| </div> | |
| {#if tip.deltaText} | |
| <div class="tip-delta" class:good={tip.deltaGood} class:bad={tip.deltaBad}>{tip.deltaText}</div> | |
| {/if} | |
| {#if tip.commits} | |
| <div class="tip-commits">@ {tip.commits}</div> | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| <div class="legend"> | |
| {#each series as s (s.name)} | |
| {@const last = s.points[s.points.length - 1]} | |
| {@const prev = s.points[s.points.length - 2]} | |
| {@const d = last && prev && prev.y !== 0 ? ((last.y - prev.y) / prev.y) * 100 : null} | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <span | |
| class="legend-item" | |
| class:dim={highlight !== null && highlight !== s.name} | |
| title={s.name} | |
| onmouseenter={() => (highlight = s.name)} | |
| onmouseleave={() => (highlight = null)} | |
| > | |
| <span class="swatch" style="background:{colorFor(s.name)}"></span> | |
| <span class="legend-name">{s.name}</span> | |
| {#if last} | |
| <span class="legend-value">{formatMetric(metric, last.y)}</span> | |
| {#if d !== null} | |
| <span | |
| class="legend-delta" | |
| class:good={d !== 0 && (lowerIsBetter ? d < 0 : d > 0)} | |
| class:bad={d !== 0 && (lowerIsBetter ? d > 0 : d < 0)} | |
| >{d > 0 ? '▲' : d < 0 ? '▼' : '•'}{Math.abs(d).toFixed(1)}%</span> | |
| {/if} | |
| {/if} | |
| </span> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| <style> | |
| .chart-card { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 0.9rem 1rem; | |
| min-width: 0; | |
| } | |
| .chart-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| gap: 0.5rem; | |
| margin-bottom: 0.4rem; | |
| } | |
| h3 { | |
| margin: 0; | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .kpi { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 0.4rem; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .kpi-value { | |
| font-size: 1.15rem; | |
| font-weight: 700; | |
| letter-spacing: -0.01em; | |
| } | |
| .kpi-delta { | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| color: var(--muted); | |
| } | |
| .kpi-delta.good { | |
| color: var(--good); | |
| } | |
| .kpi-delta.bad { | |
| color: var(--bad); | |
| } | |
| .kpi-range { | |
| font-size: 0.68rem; | |
| font-weight: 600; | |
| color: var(--muted); | |
| } | |
| .kpi-range.good { | |
| color: var(--good); | |
| } | |
| .kpi-range.bad { | |
| color: var(--bad); | |
| } | |
| .plot { | |
| position: relative; | |
| } | |
| svg { | |
| width: 100%; | |
| height: auto; | |
| display: block; | |
| } | |
| .grid { | |
| stroke: var(--border); | |
| stroke-width: 1; | |
| } | |
| .tick { | |
| fill: var(--muted); | |
| font-size: 9px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .series { | |
| transition: opacity 0.15s ease; | |
| } | |
| .line { | |
| fill: none; | |
| stroke-width: 2; | |
| } | |
| .hitline { | |
| fill: none; | |
| stroke: transparent; | |
| stroke-width: 12; | |
| pointer-events: stroke; | |
| } | |
| .hit { | |
| cursor: crosshair; | |
| } | |
| .end-label { | |
| font-size: 8px; | |
| font-weight: 600; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .tip { | |
| position: absolute; | |
| z-index: 5; | |
| transform: translate(-50%, calc(-100% - 12px)); | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 0.4rem 0.55rem; | |
| font-size: 0.72rem; | |
| line-height: 1.35; | |
| pointer-events: none; | |
| white-space: nowrap; | |
| box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); | |
| } | |
| .tip.below { | |
| transform: translate(-50%, 12px); | |
| } | |
| .tip-name { | |
| font-weight: 600; | |
| margin-bottom: 0.15rem; | |
| max-width: 16rem; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .tip-row { | |
| display: flex; | |
| gap: 0.6rem; | |
| justify-content: space-between; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .tip-date { | |
| color: var(--muted); | |
| } | |
| .tip-delta { | |
| font-weight: 600; | |
| color: var(--muted); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .tip-delta.good { | |
| color: var(--good); | |
| } | |
| .tip-delta.bad { | |
| color: var(--bad); | |
| } | |
| .tip-commits { | |
| color: var(--muted); | |
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| font-size: 0.66rem; | |
| max-width: 16rem; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .legend { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.35rem 0.9rem; | |
| margin-top: 0.5rem; | |
| font-size: 0.72rem; | |
| color: var(--muted); | |
| max-height: 7.5rem; | |
| overflow-y: auto; | |
| } | |
| .legend-item { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| white-space: nowrap; | |
| cursor: default; | |
| transition: opacity 0.15s ease; | |
| } | |
| .legend-item.dim { | |
| opacity: 0.35; | |
| } | |
| .legend-name { | |
| max-width: 16rem; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .legend-value { | |
| color: var(--fg); | |
| font-weight: 600; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .legend-delta { | |
| font-weight: 600; | |
| font-variant-numeric: tabular-nums; | |
| color: var(--muted); | |
| } | |
| .legend-delta.good { | |
| color: var(--good); | |
| } | |
| .legend-delta.bad { | |
| color: var(--bad); | |
| } | |
| .swatch { | |
| width: 9px; | |
| height: 9px; | |
| border-radius: 2px; | |
| display: inline-block; | |
| flex: none; | |
| } | |
| .empty { | |
| color: var(--muted); | |
| font-size: 0.85rem; | |
| padding: 2.5rem 0; | |
| text-align: center; | |
| } | |
| </style> | |