@ruckc/truss-chart 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  import ChartFrame from './ChartFrame.svelte';
3
3
  import Tooltip from './Tooltip.svelte';
4
4
  import type { Series } from './types.js';
5
- import { fmtAxis, fmtValue, linear, niceTicks, seriesColor, MAX_SERIES } from './util.js';
5
+ import { fmtAxis, fmtValue, linear, str, niceTicks, seriesColor, MAX_SERIES } from './util.js';
6
6
 
7
7
  let {
8
8
  categories,
@@ -13,6 +13,8 @@
13
13
  stacked = false,
14
14
  horizontal = false,
15
15
  showValues = false,
16
+ reference,
17
+ details,
16
18
  yFormat = fmtValue
17
19
  }: {
18
20
  categories: string[];
@@ -28,11 +30,16 @@
28
30
  horizontal?: boolean;
29
31
  /** Label the end of each bar (the stack total when stacked). */
30
32
  showValues?: boolean;
33
+ /** One or more labelled reference lines (a target, a limit, an average) drawn across the plot at a value on the value axis. They extend the axis domain so they are always visible. */
34
+ reference?: Ref | Ref[];
35
+ /** Extra text per category (same order as `categories`), shown under the category name in the tooltip and as a Details column in the table view. */
36
+ details?: string[];
31
37
  yFormat?: (n: number) => string;
32
38
  } = $props();
33
39
 
40
+ type Ref = { value: number; label?: string };
34
41
  let hidden = $state<string[]>([]);
35
- type Tip = { x: number; y: number; cat: string; name: string; value: number; color: string; share?: number };
42
+ type Tip = { x: number; y: number; cat: string; name: string; value: number; color: string; share?: number; detail?: string };
36
43
  let tip = $state<Tip | null>(null);
37
44
 
38
45
  const all = $derived(series.slice(0, MAX_SERIES).map((s, i) => ({ ...s, i, color: s.color ?? seriesColor(i) })));
@@ -41,7 +48,6 @@
41
48
  const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
42
49
  const percent = $derived(stacked === 'percent');
43
50
  const isStack = $derived(!!stacked);
44
- const fmt = $derived((n: number) => (percent ? `${fmtValue(n)}%` : yFormat(n)));
45
51
 
46
52
  /** One drawn segment: [lo, hi] on the value axis, per category and series. */
47
53
  type Seg = { c: number; k: number; lo: number; hi: number; v: number; sign: 1 | -1; outer: boolean; inner: boolean };
@@ -69,10 +75,13 @@
69
75
  });
70
76
  return out;
71
77
  });
78
+ const refs = $derived<Ref[]>([reference ?? []].flat());
72
79
  const scale = $derived.by(() => {
73
80
  let lo = 0, hi = 0;
74
81
  for (const s of segs) { lo = Math.min(lo, s.lo); hi = Math.max(hi, s.hi); }
82
+ const rv = refs.map((r) => r.value);
75
83
  if (percent && lo === 0) hi = 100;
84
+ lo = Math.min(lo, ...rv); hi = Math.max(hi, ...rv);
76
85
  return niceTicks(lo, hi || 1, 5);
77
86
  });
78
87
  const totals = $derived(
@@ -87,6 +96,17 @@
87
96
  return `M${x + tl},${y}H${x + w - tr}${tr ? `Q${x + w},${y} ${x + w},${y + tr}` : ''}V${y + h - br}${br ? `Q${x + w},${y + h} ${x + w - br},${y + h}` : ''}H${x + bl}${bl ? `Q${x},${y + h} ${x},${y + h - bl}` : ''}V${y + tl}${tl ? `Q${x},${y} ${x + tl},${y}` : ''}Z`;
88
97
  }
89
98
 
99
+ // reference-line values: shown as a share on 100% stacked charts
100
+ const fmt = $derived((n: number) => (percent ? `${fmtValue(n)}%` : yFormat(n)));
101
+ const refText = (r: Ref) => (r.label ? `${r.label} · ${fmt(r.value)}` : fmt(r.value));
102
+ const tipFor = (s: Seg, ser: { name: string; color: string }, x: number, y: number): Tip => ({
103
+ x, y, cat: categories[s.c], name: ser.name, value: s.v, color: ser.color,
104
+ share: percent ? Math.abs(s.hi - s.lo) : undefined, detail: details?.[s.c]
105
+ });
106
+ // built in script: interpolating these in markup would add unreachable `?? ''` branches
107
+ const barLabel = (cat: string, name: string, v: number, share: number) => `${cat}, ${name}: ${str(yFormat(v))}${percent ? ` (${fmtValue(share)}%)` : ''}`;
108
+ const tipName = (t: Tip) => `${t.name}${t.share != null ? ` · ${fmtValue(t.share)}%` : ''}`;
109
+
90
110
  const GAP = 1; // each side of a shared edge yields a 2px surface gap
91
111
  const labelWidth = $derived(Math.min(168, Math.max(48, Math.max(0, ...categories.map((c) => c.length)) * 6.4 + 14)));
92
112
  const trunc = (s: string, px: number) => (s.length * 6.4 > px ? `${s.slice(0, Math.max(1, Math.floor(px / 6.4) - 1))}…` : s);
@@ -94,7 +114,7 @@
94
114
 
95
115
  <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
96
116
  {#snippet plot(w)}
97
- {@const m = horizontal ? { top: 10, right: showValues ? 56 : 14, bottom: 26, left: labelWidth } : { top: showValues ? 22 : 12, right: 12, bottom: 28, left: 46 }}
117
+ {@const m = horizontal ? { top: refs.length ? 24 : 10, right: showValues ? 56 : 14, bottom: 26, left: labelWidth } : { top: showValues ? 22 : 12, right: 12, bottom: 28, left: 46 }}
98
118
  {@const bandN = Math.max(categories.length, 1)}
99
119
  {@const plotH = horizontal ? Math.max(height, bandN * (isStack ? 30 : Math.max(1, vis.length) * 16 + 14) + m.top + m.bottom) : height}
100
120
  {@const V = horizontal ? linear(scale.min, scale.max, m.left, w - m.right) : linear(scale.min, scale.max, plotH - m.bottom, m.top)}
@@ -144,15 +164,25 @@
144
164
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
145
165
  <path
146
166
  class="bar" fill={ser.color} role="img" tabindex="0" data-cat={categories[s.c]} data-series={ser.name}
147
- aria-label="{categories[s.c]}, {ser.name}: {yFormat(s.v)}{percent ? ` (${fmtValue(Math.abs(s.hi - s.lo))}%)` : ''}"
167
+ aria-label={barLabel(categories[s.c], ser.name, s.v, Math.abs(s.hi - s.lo))}
148
168
  {d}
149
- onpointermove={() => (tip = { x: tx, y: ty, cat: categories[s.c], name: ser.name, value: s.v, color: ser.color, share: percent ? Math.abs(s.hi - s.lo) : undefined })}
150
- onfocus={() => (tip = { x: tx, y: ty, cat: categories[s.c], name: ser.name, value: s.v, color: ser.color, share: percent ? Math.abs(s.hi - s.lo) : undefined })}
169
+ onpointermove={() => (tip = tipFor(s, ser, tx, ty))}
170
+ onfocus={() => (tip = tipFor(s, ser, tx, ty))}
151
171
  onblur={() => (tip = null)}
152
172
  />
153
173
  {/if}
154
174
  {/each}
155
175
 
176
+ {#each refs as r}
177
+ {#if horizontal}
178
+ <line class="ref" x1={V(r.value)} x2={V(r.value)} y1={m.top} y2={plotH - m.bottom} />
179
+ <text class="ref-label" x={V(r.value)} y={m.top - 6} text-anchor="middle">{refText(r)}</text>
180
+ {:else}
181
+ <line class="ref" x1={m.left} x2={w - m.right} y1={V(r.value)} y2={V(r.value)} />
182
+ <text class="ref-label" x={w - m.right} y={V(r.value) - 5} text-anchor="end">{refText(r)}</text>
183
+ {/if}
184
+ {/each}
185
+
156
186
  {#if showValues && !percent}
157
187
  {#each categories as cat, c}
158
188
  {#if isStack || vis.length}
@@ -173,19 +203,25 @@
173
203
  {#if tip}
174
204
  <Tooltip x={tip.x} y={tip.y} width={w}>
175
205
  <div class="head">{tip.cat}</div>
176
- <div class="row"><span class="line-key" style:background={tip.color}></span><b>{yFormat(tip.value)}</b><span class="name">{tip.name}{tip.share != null ? ` · ${fmtValue(tip.share)}%` : ''}</span></div>
206
+ {#if tip.detail}<div class="detail">{tip.detail}</div>{/if}
207
+ <div class="row"><span class="line-key" style:background={tip.color}></span><b>{yFormat(tip.value)}</b><span class="name">{tipName(tip)}</span></div>
177
208
  </Tooltip>
178
209
  {/if}
179
210
  {/snippet}
180
211
 
181
212
  {#snippet table()}
182
213
  <table>
183
- <thead><tr><th>Category</th>{#each all as s}<th>{s.name}</th>{/each}</tr></thead>
214
+ <thead><tr><th>Category</th>{#each all as s}<th>{s.name}</th>{/each}{#if details}<th>Details</th>{/if}</tr></thead>
184
215
  <tbody>
185
216
  {#each categories as c, i}
186
- <tr><td>{c}</td>{#each all as s}<td>{s.values[i] == null ? '—' : yFormat(s.values[i]!)}</td>{/each}</tr>
217
+ <tr><td>{c}</td>{#each all as s}<td>{s.values[i] == null ? '—' : yFormat(s.values[i]!)}</td>{/each}{#if details}<td>{details[i] ?? ''}</td>{/if}</tr>
187
218
  {/each}
188
219
  </tbody>
220
+ {#if refs.length}
221
+ <tfoot>
222
+ {#each refs as r}<tr><th scope="row">{r.label ?? 'Reference'}</th><td colspan={all.length + 1}>{fmt(r.value)}</td></tr>{/each}
223
+ </tfoot>
224
+ {/if}
189
225
  </table>
190
226
  {/snippet}
191
227
  </ChartFrame>
@@ -199,7 +235,10 @@
199
235
  .bar { outline: none; transition: filter var(--tr-dur-fast) var(--tr-ease); }
200
236
  .bar:hover, .bar:focus-visible { filter: brightness(1.12) saturate(1.1); }
201
237
  .bar:focus-visible { stroke: var(--tr-on-surface); stroke-width: 2; }
238
+ .ref { stroke: var(--tr-on-surface); stroke-width: 1.5; stroke-dasharray: 5 4; pointer-events: none; }
239
+ .ref-label { fill: var(--tr-on-surface); font-size: var(--tr-text-xs); font-weight: 600; font-variant-numeric: tabular-nums; paint-order: stroke; stroke: var(--tr-surface); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; }
202
240
  .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
241
+ .detail { color: var(--tr-on-surface-variant); margin: -2px 0 4px; max-width: 220px; white-space: normal; }
203
242
  .row { display: flex; align-items: center; gap: 8px; line-height: 1.5; }
204
243
  .row b { color: var(--tr-on-surface); font-variant-numeric: tabular-nums; }
205
244
  .name { color: var(--tr-on-surface-variant); }
@@ -1,4 +1,8 @@
1
1
  import type { Series } from './types.js';
2
+ type Ref = {
3
+ value: number;
4
+ label?: string;
5
+ };
2
6
  type $$ComponentProps = {
3
7
  categories: string[];
4
8
  /** Up to 8 series (fixed-order palette). Negative values are allowed everywhere, including stacked bars. */
@@ -13,6 +17,10 @@ type $$ComponentProps = {
13
17
  horizontal?: boolean;
14
18
  /** Label the end of each bar (the stack total when stacked). */
15
19
  showValues?: boolean;
20
+ /** One or more labelled reference lines (a target, a limit, an average) drawn across the plot at a value on the value axis. They extend the axis domain so they are always visible. */
21
+ reference?: Ref | Ref[];
22
+ /** Extra text per category (same order as `categories`), shown under the category name in the tooltip and as a Details column in the table view. */
23
+ details?: string[];
16
24
  yFormat?: (n: number) => string;
17
25
  };
18
26
  declare const BarChart: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { gradientFrom, type ScaleKind } from './util.js';
3
+
4
+ let {
5
+ low,
6
+ high,
7
+ mid,
8
+ caption,
9
+ label,
10
+ palette = 'sequential',
11
+ vertical = false,
12
+ size = 200
13
+ }: {
14
+ /** Label at the low end, e.g. `0%`. */
15
+ low: string;
16
+ /** Label at the high end, e.g. `40+%`. */
17
+ high: string;
18
+ /** Optional label at the midpoint (the neutral stop of a diverging scale). */
19
+ mid?: string;
20
+ /** Visible caption above the bar (horizontal) or beside it (vertical). */
21
+ caption?: string;
22
+ /** Accessible name. Defaults to "caption: low to high", e.g. "Error rate: 0% to 40+%". */
23
+ label?: string;
24
+ /** Which palette ramp to draw; match the ramp your marks use. */
25
+ palette?: ScaleKind;
26
+ /** Bar runs bottom (low) to top (high) instead of left to right. */
27
+ vertical?: boolean;
28
+ /** Bar length in px. */
29
+ size?: number;
30
+ } = $props();
31
+
32
+ const name = $derived(label ?? `${caption ? `${caption}: ` : ''}${low} to ${high}`);
33
+ const bg = $derived(gradientFrom(palette, vertical ? 'to top' : 'to right'));
34
+ const ends = $derived(vertical ? [high, low] : [low, high]);
35
+ </script>
36
+
37
+ <figure class="cs" class:vertical role="img" aria-label={name} style:--size="{size}px">
38
+ {#if caption}<figcaption>{caption}</figcaption>{/if}
39
+ <div class="body">
40
+ <div class="bar" style:background={bg}></div>
41
+ <div class="ends" aria-hidden="true">
42
+ <span>{ends[0]}</span>
43
+ {#if mid}<span>{mid}</span>{/if}
44
+ <span>{ends[1]}</span>
45
+ </div>
46
+ </div>
47
+ </figure>
48
+
49
+ <style>
50
+ .cs { margin: 0; display: inline-flex; flex-direction: column; gap: 4px; font-size: var(--tr-text-xs); color: var(--tr-on-surface-variant); }
51
+ figcaption { font-weight: 600; color: var(--tr-on-surface); }
52
+ .body { display: flex; flex-direction: column; gap: 3px; }
53
+ .bar { width: var(--size); height: 10px; border-radius: 3px; border: 1px solid var(--tr-outline-variant); box-sizing: border-box; }
54
+ .ends { display: flex; justify-content: space-between; font-variant-numeric: tabular-nums; }
55
+ .vertical { flex-direction: row; align-items: stretch; gap: 8px; }
56
+ .vertical .body { flex-direction: row; gap: 6px; }
57
+ .vertical .bar { width: 10px; height: var(--size); }
58
+ .vertical .ends { flex-direction: column; }
59
+ </style>
@@ -0,0 +1,22 @@
1
+ import { type ScaleKind } from './util.js';
2
+ type $$ComponentProps = {
3
+ /** Label at the low end, e.g. `0%`. */
4
+ low: string;
5
+ /** Label at the high end, e.g. `40+%`. */
6
+ high: string;
7
+ /** Optional label at the midpoint (the neutral stop of a diverging scale). */
8
+ mid?: string;
9
+ /** Visible caption above the bar (horizontal) or beside it (vertical). */
10
+ caption?: string;
11
+ /** Accessible name. Defaults to "caption: low to high", e.g. "Error rate: 0% to 40+%". */
12
+ label?: string;
13
+ /** Which palette ramp to draw; match the ramp your marks use. */
14
+ palette?: ScaleKind;
15
+ /** Bar runs bottom (low) to top (high) instead of left to right. */
16
+ vertical?: boolean;
17
+ /** Bar length in px. */
18
+ size?: number;
19
+ };
20
+ declare const ColorScale: import("svelte").Component<$$ComponentProps, {}, "">;
21
+ type ColorScale = ReturnType<typeof ColorScale>;
22
+ export default ColorScale;
@@ -0,0 +1,364 @@
1
+ <script lang="ts">
2
+ import ChartFrame from './ChartFrame.svelte';
3
+ import Tooltip from './Tooltip.svelte';
4
+ import { bin, DEFAULT_RAMP, isHome, nearest, panView, parseColor, prepare, rampColor, zoomView, type DensityPoint, type View } from './density.js';
5
+ import { fmtAxis, fmtValue, linear, niceTicks, seriesColor } from './util.js';
6
+
7
+ let {
8
+ points,
9
+ xs,
10
+ ys,
11
+ groupIds,
12
+ groupNames,
13
+ title,
14
+ subtitle,
15
+ height = 320,
16
+ xLabel,
17
+ yLabel,
18
+ xFormat = fmtValue,
19
+ yFormat = fmtValue,
20
+ cellSize = 8,
21
+ markerThreshold = 2,
22
+ markerSize = 3
23
+ }: {
24
+ /** Object input, convenient up to ~10^5 points. `group` picks the palette colour of markers and a legend entry (max 8). */
25
+ points?: DensityPoint[];
26
+ /** Typed-array input for 10^5 to 10^6+ points (used as-is, no copy); wins over `points` when both `xs` and `ys` are set. */
27
+ xs?: ArrayLike<number>;
28
+ ys?: ArrayLike<number>;
29
+ /** Optional palette slot (0-7) per point for typed-array input, with `groupNames` naming the slots. */
30
+ groupIds?: Uint8Array;
31
+ groupNames?: string[];
32
+ title?: string;
33
+ subtitle?: string;
34
+ height?: number;
35
+ xLabel?: string;
36
+ yLabel?: string;
37
+ xFormat?: (n: number) => string;
38
+ yFormat?: (n: number) => string;
39
+ /** Heatmap cell edge in CSS px. */
40
+ cellSize?: number;
41
+ /**
42
+ * Switch from heatmap to individual markers once the visible points number at most this many per heatmap cell.
43
+ * Colour ramp: `--trc-seq-1` … `--trc-seq-9` (low to high count); falls back to a built-in colour-blind-safe ramp.
44
+ */
45
+ markerThreshold?: number;
46
+ /** Marker edge in CSS px. */
47
+ markerSize?: number;
48
+ } = $props();
49
+
50
+ const data = $derived(prepare(points, xs, ys, groupIds, groupNames));
51
+ let hidden = $state<string[]>([]);
52
+ const hid = $derived(Uint8Array.from({ length: 8 }, (_, i) => (hidden.includes(data.names[i]) ? 1 : 0)));
53
+ const items = $derived(data.names.map((name, i) => ({ name, color: seriesColor(i), hidden: hidden.includes(name) })));
54
+ const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
55
+
56
+ const home = $derived.by<View>(() => {
57
+ const e = data.extent;
58
+ if (!e) return { x0: 0, x1: 1, y0: 0, y1: 1 };
59
+ const xt = niceTicks(e.x0, e.x1, 6), yt = niceTicks(e.y0, e.y1, 5);
60
+ return { x0: xt.min, x1: xt.max, y0: yt.min, y1: yt.max };
61
+ });
62
+ let zoom = $state<View | null>(null);
63
+ const cur = $derived(zoom ?? home);
64
+ // new data starts from the full view
65
+ $effect(() => { void data; zoom = null; });
66
+
67
+ let cw = $state(640);
68
+ let canvas = $state<HTMLCanvasElement | null>(null);
69
+ let box = $state<HTMLDivElement | null>(null);
70
+ let mode = $state<'heatmap' | 'markers' | ''>('');
71
+ let inView = $state(0);
72
+ let hover = $state<{ x: number; y: number; head: string; rows: [string, string][]; rx: number; ry: number; rw: number; rh: number } | null>(null);
73
+
74
+ const m = $derived({ top: 32, right: 16, bottom: xLabel ? 44 : 28, left: yLabel ? 62 : 48 });
75
+ const pw = $derived(Math.max(1, cw - m.left - m.right));
76
+ const ph = $derived(Math.max(1, height - m.top - m.bottom));
77
+ const total = $derived(data.n);
78
+
79
+ const readout = $derived(
80
+ `${fmtValue(inView)} of ${fmtValue(total)} points in view, drawn as ${mode === 'markers' ? 'individual markers' : 'a density heatmap'}. ` +
81
+ `${xLabel ?? 'x'} ${xFormat(cur.x0)} to ${xFormat(cur.x1)}, ${yLabel ?? 'y'} ${yFormat(cur.y0)} to ${yFormat(cur.y1)}.`
82
+ );
83
+
84
+ // ── drawing (redraw once per frame, whatever changed)
85
+ let grid = new Uint32Array(0), gCols = 1, gRows = 1;
86
+ let raf = 0;
87
+ const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
88
+ $effect(() => {
89
+ void [canvas, cw, height, cur, data, hid, cellSize, markerThreshold, markerSize, m];
90
+ schedule();
91
+ return () => { cancelAnimationFrame(raf); raf = 0; };
92
+ });
93
+ // colours come from CSS custom properties, so follow theme switches (explicit data-theme or the OS preference)
94
+ $effect(() => {
95
+ const mo = new MutationObserver(schedule);
96
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
97
+ // the default theme follows the OS, which changes without touching any attribute
98
+ const os = matchMedia('(prefers-color-scheme: dark)');
99
+ os.addEventListener('change', schedule);
100
+ return () => { mo.disconnect(); os.removeEventListener('change', schedule); };
101
+ });
102
+
103
+ const cssVar = (st: CSSStyleDeclaration, name: string, fallback: string) => st.getPropertyValue(name).trim() || fallback;
104
+
105
+ function draw() {
106
+ raf = 0;
107
+ const c = canvas;
108
+ if (!c) return;
109
+ const ctx = c.getContext('2d')!;
110
+ const dpr = window.devicePixelRatio;
111
+ const W = cw, H = height;
112
+ if (c.width !== Math.round(W * dpr)) c.width = Math.round(W * dpr);
113
+ if (c.height !== Math.round(H * dpr)) c.height = Math.round(H * dpr);
114
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
115
+ ctx.clearRect(0, 0, W, H);
116
+ const st = getComputedStyle(c);
117
+ const v = cur;
118
+ const X = linear(v.x0, v.x1, m.left, m.left + pw), Y = linear(v.y0, v.y1, m.top + ph, m.top);
119
+
120
+ // axes
121
+ ctx.font = `11px ${cssVar(st, '--tr-font-sans', 'sans-serif')}`;
122
+ ctx.fillStyle = cssVar(st, '--tr-on-surface-variant', '#666');
123
+ ctx.strokeStyle = cssVar(st, '--tr-outline-variant', '#ccc');
124
+ ctx.globalAlpha = 1;
125
+ ctx.lineWidth = 1;
126
+ ctx.textBaseline = 'middle';
127
+ ctx.textAlign = 'right';
128
+ for (const t of niceTicks(v.y0, v.y1, 5).ticks) {
129
+ if (t < v.y0 || t > v.y1) continue;
130
+ const y = Math.round(Y(t)) + 0.5;
131
+ ctx.globalAlpha = 0.6;
132
+ ctx.beginPath(); ctx.moveTo(m.left, y); ctx.lineTo(m.left + pw, y); ctx.stroke();
133
+ ctx.globalAlpha = 1;
134
+ ctx.fillText(fmtAxis(t), m.left - 8, y);
135
+ }
136
+ ctx.textAlign = 'center';
137
+ for (const t of niceTicks(v.x0, v.x1, 6).ticks) if (t >= v.x0 && t <= v.x1) ctx.fillText(fmtAxis(t), X(t), m.top + ph + 16);
138
+ ctx.font = `600 ${ctx.font}`;
139
+ if (xLabel) ctx.fillText(xLabel, m.left + pw / 2, H - 8);
140
+ if (yLabel) {
141
+ ctx.save();
142
+ ctx.translate(12, m.top + ph / 2);
143
+ ctx.rotate(-Math.PI / 2);
144
+ ctx.fillText(yLabel, 0, 0);
145
+ ctx.restore();
146
+ }
147
+
148
+ // data: one binning pass decides heatmap vs markers
149
+ gCols = Math.max(1, Math.ceil(pw / cellSize));
150
+ gRows = Math.max(1, Math.ceil(ph / cellSize));
151
+ if (grid.length < gCols * gRows) grid = new Uint32Array(gCols * gRows);
152
+ const n = bin(data, hid, v, gCols, gRows, grid);
153
+ const markers = n <= markerThreshold * gCols * gRows;
154
+ ctx.save();
155
+ ctx.beginPath();
156
+ ctx.rect(m.left, m.top, pw, ph);
157
+ ctx.clip();
158
+ if (markers) {
159
+ const slots = Array.from({ length: 8 }, (_, i) => {
160
+ ctx.fillStyle = '#000';
161
+ ctx.fillStyle = cssVar(st, `--trc-s${i + 1}`, '#2a78d6');
162
+ return ctx.fillStyle as string;
163
+ });
164
+ const { xs: dx, ys: dy, g } = data;
165
+ const half = markerSize / 2;
166
+ const x0 = v.x0, x1 = v.x1, y0 = v.y0, y1 = v.y1;
167
+ ctx.globalAlpha = 0.85;
168
+ let last = -1;
169
+ for (let i = 0; i < data.n; i++) {
170
+ const x = dx[i], y = dy[i];
171
+ if (!(x >= x0 && x <= x1 && y >= y0 && y <= y1)) continue;
172
+ const id = g === null ? 0 : g[i];
173
+ if (g !== null && hid[id] === 1) continue;
174
+ if (id !== last) { ctx.fillStyle = slots[id]; last = id; }
175
+ ctx.fillRect(X(x) - half, Y(y) - half, markerSize, markerSize);
176
+ }
177
+ } else {
178
+ const stops: [number, number, number][] = [];
179
+ for (let i = 1; i <= 9; i++) {
180
+ const raw = st.getPropertyValue(`--trc-seq-${i}`).trim();
181
+ if (!raw) break;
182
+ ctx.fillStyle = '#000';
183
+ ctx.fillStyle = raw;
184
+ stops.push(parseColor(ctx.fillStyle as string));
185
+ }
186
+ const ramp = stops.length > 1 ? stops : DEFAULT_RAMP.map(parseColor);
187
+ let max = 1;
188
+ for (let i = 0; i < gCols * gRows; i++) if (grid[i] > max) max = grid[i];
189
+ const lmax = Math.log1p(max), cw1 = pw / gCols, ch1 = ph / gRows;
190
+ for (let r = 0; r < gRows; r++)
191
+ for (let k = 0; k < gCols; k++) {
192
+ const cnt = grid[r * gCols + k];
193
+ if (!cnt) continue;
194
+ ctx.fillStyle = rampColor(ramp, Math.log1p(cnt) / lmax);
195
+ ctx.fillRect(m.left + k * cw1, m.top + (gRows - 1 - r) * ch1, cw1 + 0.5, ch1 + 0.5);
196
+ }
197
+ }
198
+ ctx.restore();
199
+ inView = n;
200
+ mode = markers ? 'markers' : 'heatmap';
201
+ }
202
+
203
+ // ── view changes
204
+ const commit = (v: View) => (zoom = isHome(v, home) ? null : v);
205
+ function zoomAt(px: number, py: number, k: number) {
206
+ commit(zoomView(cur, home, Math.min(1, Math.max(0, (px - m.left) / pw)), Math.min(1, Math.max(0, (py - m.top) / ph)), k));
207
+ hover = null;
208
+ }
209
+ const pan = (fx: number, fy: number) => { commit(panView(cur, home, fx, fy)); hover = null; };
210
+ const reset = () => { zoom = null; hover = null; };
211
+
212
+ $effect(() => {
213
+ const el = box;
214
+ if (!el) return;
215
+ const onwheel = (e: WheelEvent) => {
216
+ e.preventDefault();
217
+ const r = el.getBoundingClientRect();
218
+ zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-e.deltaY * 0.0015));
219
+ };
220
+ el.addEventListener('wheel', onwheel, { passive: false });
221
+ return () => el.removeEventListener('wheel', onwheel);
222
+ });
223
+
224
+ // ── pointers: drag pans, two pointers pinch
225
+ const ptrs = new Map<number, { x: number; y: number }>();
226
+ const spread = () => { const [a, b] = [...ptrs.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
227
+ function down(e: PointerEvent) {
228
+ ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });
229
+ hover = null;
230
+ try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* synthetic pointer */ }
231
+ }
232
+ function move(e: PointerEvent) {
233
+ const prev = ptrs.get(e.pointerId);
234
+ if (!prev) return hit(e);
235
+ if (ptrs.size === 1) {
236
+ pan(-(e.clientX - prev.x) / pw, (e.clientY - prev.y) / ph);
237
+ ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });
238
+ } else {
239
+ const before = spread();
240
+ ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });
241
+ const [a, b] = [...ptrs.values()];
242
+ const r = box!.getBoundingClientRect();
243
+ zoomAt((a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top, spread() / (before || 1));
244
+ }
245
+ }
246
+ const up = (e: PointerEvent) => { ptrs.delete(e.pointerId); };
247
+
248
+ // ── hit testing: nearest marker when zoomed in, bin count when zoomed out
249
+ function hit(e: PointerEvent) {
250
+ const r = box!.getBoundingClientRect();
251
+ const px = e.clientX - r.left, py = e.clientY - r.top;
252
+ if (px < m.left || px > m.left + pw || py < m.top || py > m.top + ph) { hover = null; return; }
253
+ const v = cur;
254
+ const qx = v.x0 + ((px - m.left) / pw) * (v.x1 - v.x0), qy = v.y1 - ((py - m.top) / ph) * (v.y1 - v.y0);
255
+ if (mode === 'markers') {
256
+ const i = nearest(data, hid, qx, qy, (12 / pw) * (v.x1 - v.x0), (12 / ph) * (v.y1 - v.y0));
257
+ if (i < 0) { hover = null; return; }
258
+ const x = m.left + ((data.xs[i] - v.x0) / (v.x1 - v.x0)) * pw, y = m.top + ph - ((data.ys[i] - v.y0) / (v.y1 - v.y0)) * ph;
259
+ const name = data.g ? data.names[data.g[i]] : undefined;
260
+ hover = { x, y, head: name ?? 'Point', rows: [[xFormat(data.xs[i]), xLabel ?? 'x'], [yFormat(data.ys[i]), yLabel ?? 'y']], rx: x - 6, ry: y - 6, rw: 12, rh: 12 };
261
+ return;
262
+ }
263
+ const cw1 = pw / gCols, ch1 = ph / gRows;
264
+ const k = Math.min(gCols - 1, Math.floor((px - m.left) / cw1)), q = Math.min(gRows - 1, Math.floor((py - m.top) / ch1));
265
+ const cnt = grid[(gRows - 1 - q) * gCols + k];
266
+ if (!cnt) { hover = null; return; }
267
+ const sx = (v.x1 - v.x0) / gCols, sy = (v.y1 - v.y0) / gRows;
268
+ const bx = v.x0 + k * sx, by = v.y1 - (q + 1) * sy;
269
+ hover = {
270
+ x: px, y: py, head: 'Bin',
271
+ rows: [[fmtValue(cnt), 'points'], [`${xFormat(bx)} to ${xFormat(bx + sx)}`, xLabel ?? 'x'], [`${yFormat(by)} to ${yFormat(by + sy)}`, yLabel ?? 'y']],
272
+ rx: m.left + k * cw1, ry: m.top + q * ch1, rw: cw1, rh: ch1
273
+ };
274
+ }
275
+
276
+ function key(e: KeyboardEvent) {
277
+ const cx = m.left + pw / 2, cy = m.top + ph / 2;
278
+ if (e.key === '+' || e.key === '=') zoomAt(cx, cy, 1.5);
279
+ else if (e.key === '-' || e.key === '_') zoomAt(cx, cy, 1 / 1.5);
280
+ else if (e.key === '0') reset();
281
+ else if (e.key === 'ArrowLeft') pan(-0.1, 0);
282
+ else if (e.key === 'ArrowRight') pan(0.1, 0);
283
+ else if (e.key === 'ArrowUp') pan(0, 0.1);
284
+ else if (e.key === 'ArrowDown') pan(0, -0.1);
285
+ else if (e.key === 'Escape') hover = null;
286
+ else return;
287
+ e.preventDefault();
288
+ }
289
+
290
+ // ── table: busiest regions of the current view, never the raw rows
291
+ const TC = 8, TR = 6;
292
+ const summary = $derived.by(() => {
293
+ const out = new Uint32Array(TC * TR);
294
+ const n = bin(data, hid, cur, TC, TR, out);
295
+ const sx = (cur.x1 - cur.x0) / TC, sy = (cur.y1 - cur.y0) / TR;
296
+ const rows: { x: string; y: string; count: number; share: string }[] = [];
297
+ for (let i = 0; i < out.length; i++)
298
+ if (out[i]) {
299
+ const c = i % TC, r = (i - c) / TC;
300
+ rows.push({ x: `${xFormat(cur.x0 + c * sx)} to ${xFormat(cur.x0 + (c + 1) * sx)}`, y: `${yFormat(cur.y0 + r * sy)} to ${yFormat(cur.y0 + (r + 1) * sy)}`, count: out[i], share: `${((out[i] / n) * 100).toFixed(1)}%` });
301
+ }
302
+ rows.sort((a, b) => b.count - a.count);
303
+ return { n, regions: rows.length, rows: rows.slice(0, 12) };
304
+ });
305
+ </script>
306
+
307
+ <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
308
+ {#snippet plot(w)}
309
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
310
+ <div
311
+ class="dc" role="group" tabindex="0" bind:this={box} bind:clientWidth={cw}
312
+ aria-label="{title ?? 'Density chart'}, {fmtValue(total)} points. Drag or arrow keys pan, wheel or plus and minus zoom, 0 resets the view."
313
+ onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}
314
+ onpointerleave={() => (hover = null)} onblur={() => (hover = null)} onkeydown={key}
315
+ ondblclick={(e) => { const r = box!.getBoundingClientRect(); zoomAt(e.clientX - r.left, e.clientY - r.top, 2); }}
316
+ >
317
+ <canvas bind:this={canvas} style:height="{height}px" aria-hidden="true" data-mode={mode || undefined}></canvas>
318
+ {#if hover}<div class="ring" style="left: {hover.rx}px; top: {hover.ry}px; width: {hover.rw}px; height: {hover.rh}px"></div>{/if}
319
+ </div>
320
+ <button type="button" class="reset" disabled={zoom === null} onclick={reset}>Reset view</button>
321
+ <div class="sr" role="status" aria-live="polite">{readout}</div>
322
+
323
+ {#if hover}
324
+ <Tooltip x={hover.x} y={hover.y} width={w}>
325
+ <div class="head">{hover.head}</div>
326
+ {#each hover.rows as [val, name]}
327
+ <div class="row"><b>{val}</b><span class="name">{name}</span></div>
328
+ {/each}
329
+ </Tooltip>
330
+ {/if}
331
+ {/snippet}
332
+
333
+ {#snippet table()}
334
+ <table>
335
+ <caption>{fmtValue(summary.n)} points in view, grouped into {summary.regions} regions{summary.regions > summary.rows.length ? ` (busiest ${summary.rows.length} shown)` : ''}</caption>
336
+ <thead><tr><th>{xLabel ?? 'X'}</th><th>{yLabel ?? 'Y'}</th><th>Points</th><th>Share</th></tr></thead>
337
+ <tbody>
338
+ {#each summary.rows as r}
339
+ <tr><td>{r.x}</td><td>{r.y}</td><td>{fmtValue(r.count)}</td><td>{r.share}</td></tr>
340
+ {:else}
341
+ <tr><td colspan="4">No points in view</td></tr>
342
+ {/each}
343
+ </tbody>
344
+ </table>
345
+ {/snippet}
346
+ </ChartFrame>
347
+
348
+ <style>
349
+ .dc { position: relative; touch-action: none; cursor: grab; outline: none; user-select: none; }
350
+ .dc:active { cursor: grabbing; }
351
+ .dc:focus-visible { border-radius: var(--tr-radius-sm); box-shadow: var(--tr-focus-ring); }
352
+ canvas { display: block; width: 100%; }
353
+ .ring { position: absolute; border: 2px solid var(--tr-on-surface); border-radius: 3px; pointer-events: none; box-sizing: border-box; }
354
+ .reset { position: absolute; top: 0; right: 0; height: 24px; padding: 0 10px; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-sm); background: var(--tr-surface-container-low); color: var(--tr-on-surface-variant); font: 600 var(--tr-text-sm) var(--tr-font-sans); cursor: pointer; }
355
+ .reset:hover:not(:disabled) { background: var(--tr-surface-container-high); }
356
+ .reset:disabled { opacity: 0.45; cursor: default; }
357
+ .reset:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
358
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
359
+ caption { caption-side: top; padding: 4px 8px; text-align: left; color: var(--tr-on-surface-variant); }
360
+ .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
361
+ .row { display: flex; align-items: center; gap: 8px; line-height: 1.5; }
362
+ .row b { color: var(--tr-on-surface); font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; }
363
+ .name { color: var(--tr-on-surface-variant); }
364
+ </style>
@@ -0,0 +1,30 @@
1
+ import { type DensityPoint } from './density.js';
2
+ type $$ComponentProps = {
3
+ /** Object input, convenient up to ~10^5 points. `group` picks the palette colour of markers and a legend entry (max 8). */
4
+ points?: DensityPoint[];
5
+ /** Typed-array input for 10^5 to 10^6+ points (used as-is, no copy); wins over `points` when both `xs` and `ys` are set. */
6
+ xs?: ArrayLike<number>;
7
+ ys?: ArrayLike<number>;
8
+ /** Optional palette slot (0-7) per point for typed-array input, with `groupNames` naming the slots. */
9
+ groupIds?: Uint8Array;
10
+ groupNames?: string[];
11
+ title?: string;
12
+ subtitle?: string;
13
+ height?: number;
14
+ xLabel?: string;
15
+ yLabel?: string;
16
+ xFormat?: (n: number) => string;
17
+ yFormat?: (n: number) => string;
18
+ /** Heatmap cell edge in CSS px. */
19
+ cellSize?: number;
20
+ /**
21
+ * Switch from heatmap to individual markers once the visible points number at most this many per heatmap cell.
22
+ * Colour ramp: `--trc-seq-1` … `--trc-seq-9` (low to high count); falls back to a built-in colour-blind-safe ramp.
23
+ */
24
+ markerThreshold?: number;
25
+ /** Marker edge in CSS px. */
26
+ markerSize?: number;
27
+ };
28
+ declare const DensityChart: import("svelte").Component<$$ComponentProps, {}, "">;
29
+ type DensityChart = ReturnType<typeof DensityChart>;
30
+ export default DensityChart;