hfinfer / src /lib /LineChart.svelte
Morgan Funtowicz
UX iteration: robust faceted filtering, chart overhaul, schema v1.1 docs
59a688b
Raw History Blame
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>