@ruckc/truss-chart 1.8.0 → 1.10.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.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import ChartFrame from './ChartFrame.svelte';
3
+ import { watchFill } from './fill.js';
3
4
  import Tooltip from './Tooltip.svelte';
4
5
  import type { Series } from './types.js';
5
6
  import { fmtAxis, fmtValue, linear, str, niceTicks, seriesColor, MAX_SERIES } from './util.js';
@@ -22,8 +23,12 @@
22
23
  series: Series[];
23
24
  title?: string;
24
25
  subtitle?: string;
25
- /** Plot height in px (horizontal charts grow to fit their categories). */
26
- height?: number;
26
+ /**
27
+ * Plot height in px (default 240), or `'fill'` to size to the container's height. `'fill'` measures the
28
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
29
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum. Horizontal charts still grow to fit their categories.
30
+ */
31
+ height?: number | 'fill';
27
32
  /** `true` stacks values (positives up/right of zero, negatives down/left); `'percent'` normalises each category to 100%. */
28
33
  stacked?: boolean | 'percent';
29
34
  /** Bars run left→right with categories down the side — better for long labels and many categories. */
@@ -110,10 +115,22 @@
110
115
  const GAP = 1; // each side of a shared edge yields a 2px surface gap
111
116
  const labelWidth = $derived(Math.min(168, Math.max(48, Math.max(0, ...categories.map((c) => c.length)) * 6.4 + 14)));
112
117
  const trunc = (s: string, px: number) => (s.length * 6.4 > px ? `${s.slice(0, Math.max(1, Math.floor(px / 6.4) - 1))}…` : s);
118
+
119
+ // height='fill': plot = container height minus the frame's own chrome (title, legend, key, padding)
120
+ let wrap = $state<HTMLElement>();
121
+ let fillH = $state(240);
122
+ const fill = $derived(height === 'fill');
123
+ const H = $derived(height === 'fill' ? fillH : height);
124
+ $effect(() => {
125
+ if (!fill || !wrap) return;
126
+ return watchFill(wrap, 'svg[role=img]', (h) => (fillH = h));
127
+ });
113
128
  </script>
114
129
 
130
+ {#snippet chart()}
115
131
  <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
116
132
  {#snippet plot(w)}
133
+ {@const height = H}
117
134
  {@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 }}
118
135
  {@const bandN = Math.max(categories.length, 1)}
119
136
  {@const plotH = horizontal ? Math.max(height, bandN * (isStack ? 30 : Math.max(1, vis.length) * 16 + 14) + m.top + m.bottom) : height}
@@ -225,8 +242,16 @@
225
242
  </table>
226
243
  {/snippet}
227
244
  </ChartFrame>
245
+ {/snippet}
246
+
247
+ {#if fill}
248
+ <div class="fillwrap" bind:this={wrap}>{@render chart()}</div>
249
+ {:else}
250
+ {@render chart()}
251
+ {/if}
228
252
 
229
253
  <style>
254
+ .fillwrap { height: 100%; min-height: 0; overflow: hidden; }
230
255
  svg { display: block; overflow: visible; }
231
256
  .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; }
232
257
  .grid[data-zero='true'] { stroke-opacity: 1; }
@@ -9,8 +9,12 @@ type $$ComponentProps = {
9
9
  series: Series[];
10
10
  title?: string;
11
11
  subtitle?: string;
12
- /** Plot height in px (horizontal charts grow to fit their categories). */
13
- height?: number;
12
+ /**
13
+ * Plot height in px (default 240), or `'fill'` to size to the container's height. `'fill'` measures the
14
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
15
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum. Horizontal charts still grow to fit their categories.
16
+ */
17
+ height?: number | 'fill';
14
18
  /** `true` stacks values (positives up/right of zero, negatives down/left); `'percent'` normalises each category to 100%. */
15
19
  stacked?: boolean | 'percent';
16
20
  /** Bars run left→right with categories down the side — better for long labels and many categories. */
@@ -8,6 +8,8 @@
8
8
  caption,
9
9
  label,
10
10
  palette = 'sequential',
11
+ gradient,
12
+ stops,
11
13
  vertical = false,
12
14
  size = 200
13
15
  }: {
@@ -23,6 +25,17 @@
23
25
  label?: string;
24
26
  /** Which palette ramp to draw; match the ramp your marks use. */
25
27
  palette?: ScaleKind;
28
+ /**
29
+ * Any CSS gradient (e.g. `linear-gradient(to right, #fff, #06c)`), drawn exactly as given: write the
30
+ * direction yourself (`to top` for a `vertical` bar). Takes precedence over `stops` and `palette`.
31
+ */
32
+ gradient?: string;
33
+ /**
34
+ * Colours from the low end to the high end, evenly spaced, e.g. `['#fff', '#06c', '#003']`. Built into a
35
+ * linear gradient that runs in the bar's direction (`vertical` runs bottom to top). Colours may be any CSS
36
+ * colour or `var(--token)`. Takes precedence over `palette`; ignored when `gradient` is set, or with fewer than two stops.
37
+ */
38
+ stops?: string[];
26
39
  /** Bar runs bottom (low) to top (high) instead of left to right. */
27
40
  vertical?: boolean;
28
41
  /** Bar length in px. */
@@ -30,7 +43,13 @@
30
43
  } = $props();
31
44
 
32
45
  const name = $derived(label ?? `${caption ? `${caption}: ` : ''}${low} to ${high}`);
33
- const bg = $derived(gradientFrom(palette, vertical ? 'to top' : 'to right'));
46
+ // precedence: gradient > stops (2 or more) > palette
47
+ const bg = $derived.by(() => {
48
+ const dir = vertical ? 'to top' : 'to right';
49
+ if (gradient) return gradient;
50
+ if (stops && stops.length > 1) return `linear-gradient(${dir}, ${stops.join(', ')})`;
51
+ return gradientFrom(palette, dir);
52
+ });
34
53
  const ends = $derived(vertical ? [high, low] : [low, high]);
35
54
  </script>
36
55
 
@@ -12,6 +12,17 @@ type $$ComponentProps = {
12
12
  label?: string;
13
13
  /** Which palette ramp to draw; match the ramp your marks use. */
14
14
  palette?: ScaleKind;
15
+ /**
16
+ * Any CSS gradient (e.g. `linear-gradient(to right, #fff, #06c)`), drawn exactly as given: write the
17
+ * direction yourself (`to top` for a `vertical` bar). Takes precedence over `stops` and `palette`.
18
+ */
19
+ gradient?: string;
20
+ /**
21
+ * Colours from the low end to the high end, evenly spaced, e.g. `['#fff', '#06c', '#003']`. Built into a
22
+ * linear gradient that runs in the bar's direction (`vertical` runs bottom to top). Colours may be any CSS
23
+ * colour or `var(--token)`. Takes precedence over `palette`; ignored when `gradient` is set, or with fewer than two stops.
24
+ */
25
+ stops?: string[];
15
26
  /** Bar runs bottom (low) to top (high) instead of left to right. */
16
27
  vertical?: boolean;
17
28
  /** Bar length in px. */
@@ -1,7 +1,10 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import ChartFrame from './ChartFrame.svelte';
4
+ import { watchFill } from './fill.js';
5
+ import ColorScale from './ColorScale.svelte';
3
6
  import Tooltip from './Tooltip.svelte';
4
- import { bin, DEFAULT_RAMP, isHome, nearest, panView, parseColor, prepare, rampColor, zoomView, type DensityPoint, type View } from './density.js';
7
+ import { bin, DEFAULT_RAMP, extentOf, isHome, mergeExtent, nearest, panView, parseColor, prepare, rampColor, zoomView, type DensityHighlight, type DensityHover, type DensityPoint, type Prepared, type View } from './density.js';
5
8
  import { fmtAxis, fmtValue, linear, niceTicks, seriesColor } from './util.js';
6
9
 
7
10
  let {
@@ -19,7 +22,13 @@
19
22
  yFormat = fmtValue,
20
23
  cellSize = 8,
21
24
  markerThreshold = 2,
22
- markerSize = 3
25
+ markerSize = 3,
26
+ highlight,
27
+ highlightSize = 9,
28
+ tooltip,
29
+ colorKey = true,
30
+ maxCount = $bindable(0),
31
+ ramp = $bindable([])
23
32
  }: {
24
33
  /** Object input, convenient up to ~10^5 points. `group` picks the palette colour of markers and a legend entry (max 8). */
25
34
  points?: DensityPoint[];
@@ -31,7 +40,12 @@
31
40
  groupNames?: string[];
32
41
  title?: string;
33
42
  subtitle?: string;
34
- height?: number;
43
+ /**
44
+ * Plot height in px (default 320), or `'fill'` to size to the container's height. `'fill'` measures the
45
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
46
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
47
+ */
48
+ height?: number | 'fill';
35
49
  xLabel?: string;
36
50
  yLabel?: string;
37
51
  xFormat?: (n: number) => string;
@@ -45,16 +59,44 @@
45
59
  markerThreshold?: number;
46
60
  /** Marker edge in CSS px. */
47
61
  markerSize?: number;
62
+ /**
63
+ * A small set of notable points that stay drawn as markers on top of the heatmap at every zoom, with their
64
+ * own legend entry (toggleable), tooltip hit-testing and a table section. Pass them separately from `points` /
65
+ * `xs`: highlights are not part of the bin counts, so do not repeat them in the main data. Cost is linear in
66
+ * the highlight count per frame, so keep the set small (thousands, not 10^5).
67
+ */
68
+ highlight?: DensityHighlight;
69
+ /** Highlight marker diameter in CSS px (a surface-coloured halo is added around it). */
70
+ highlightSize?: number;
71
+ /** Replaces the default tooltip body. Receives the hovered point (index, x, y, group, highlight flag) or bin (count and ranges). */
72
+ tooltip?: Snippet<[item: DensityHover]>;
73
+ /** Show a colour key (points per cell, 1 to the busiest cell in view) under the plot while in heatmap mode. */
74
+ colorKey?: boolean;
75
+ /** Bindable: the busiest cell's point count in the current view (heatmap mode; 0 while drawing markers). Cells are coloured on a log scale from 1 to this value. */
76
+ maxCount?: number;
77
+ /** Bindable: the resolved heatmap ramp, low to high, as CSS colours (from `--trc-seq-*` or the built-in fallback). */
78
+ ramp?: string[];
48
79
  } = $props();
49
80
 
50
- const data = $derived(prepare(points, xs, ys, groupIds, groupNames));
81
+ const full = $derived(prepare(points, xs, ys, groupIds, groupNames));
51
82
  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) })));
83
+ const hlName = $derived(highlight?.name ?? 'Highlighted');
84
+ const hlHidden = $derived(hidden.includes(hlName));
85
+ // an ungrouped set gets a "Points" legend entry once highlights exist, so the two can be toggled independently
86
+ const dotsEntry = $derived(!!highlight && full.names.length === 0);
87
+ const dotsHidden = $derived(dotsEntry && hidden.includes('Points'));
88
+ const data = $derived<Prepared>(dotsHidden ? { ...full, n: 0 } : full);
89
+ const hid = $derived(Uint8Array.from({ length: 8 }, (_, i) => (hidden.includes(full.names[i]) ? 1 : 0)));
90
+ const hl = $derived<Prepared | null>(highlight ? { xs: highlight.xs, ys: highlight.ys, g: null, names: [], n: Math.min(highlight.xs.length, highlight.ys.length), extent: null } : null);
91
+ const items = $derived([
92
+ ...(dotsEntry ? [{ name: 'Points', color: seriesColor(0), hidden: dotsHidden }] : full.names.map((name, i) => ({ name, color: seriesColor(i), hidden: hidden.includes(name) }))),
93
+ ...(highlight ? [{ name: hlName, color: 'var(--tr-on-surface)', hidden: hlHidden }] : [])
94
+ ]);
95
+ const noHid = new Uint8Array(8);
54
96
  const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
55
97
 
56
98
  const home = $derived.by<View>(() => {
57
- const e = data.extent;
99
+ const e = mergeExtent(full.extent, hl ? extentOf(hl.xs, hl.ys, hl.n) : null);
58
100
  if (!e) return { x0: 0, x1: 1, y0: 0, y1: 1 };
59
101
  const xt = niceTicks(e.x0, e.x1, 6), yt = niceTicks(e.y0, e.y1, 5);
60
102
  return { x0: xt.min, x1: xt.max, y0: yt.min, y1: yt.max };
@@ -62,23 +104,42 @@
62
104
  let zoom = $state<View | null>(null);
63
105
  const cur = $derived(zoom ?? home);
64
106
  // new data starts from the full view
65
- $effect(() => { void data; zoom = null; });
107
+ $effect(() => { void full; void hl; zoom = null; });
66
108
 
67
109
  let cw = $state(640);
68
110
  let canvas = $state<HTMLCanvasElement | null>(null);
69
111
  let box = $state<HTMLDivElement | null>(null);
70
112
  let mode = $state<'heatmap' | 'markers' | ''>('');
71
113
  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);
114
+ let peak = $state(0);
115
+ let hover = $state<{ x: number; y: number; head: string; rows: [string, string][]; rx: number; ry: number; rw: number; rh: number; item: DensityHover } | null>(null);
116
+
117
+ // height='fill': plot = container height minus the frame's own chrome (title, legend, key, padding)
118
+ let wrap = $state<HTMLElement>();
119
+ let fillH = $state(320);
120
+ const fill = $derived(height === 'fill');
121
+ const H = $derived(height === 'fill' ? fillH : height);
122
+ $effect(() => {
123
+ void [mode, colorKey, items.length];
124
+ if (!fill || !wrap) return;
125
+ return watchFill(wrap, 'canvas', (h) => (fillH = h));
126
+ });
73
127
 
74
128
  const m = $derived({ top: 32, right: 16, bottom: xLabel ? 44 : 28, left: yLabel ? 62 : 48 });
75
129
  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);
130
+ const ph = $derived(Math.max(1, H - m.top - m.bottom));
131
+ const total = $derived(full.n);
132
+ const hlIn = $derived.by(() => {
133
+ if (!hl || hlHidden) return 0;
134
+ let k = 0;
135
+ for (let i = 0; i < hl.n; i++) if (hl.xs[i] >= cur.x0 && hl.xs[i] <= cur.x1 && hl.ys[i] >= cur.y0 && hl.ys[i] <= cur.y1) k++;
136
+ return k;
137
+ });
78
138
 
79
139
  const readout = $derived(
80
140
  `${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)}.`
141
+ `${xLabel ?? 'x'} ${xFormat(cur.x0)} to ${xFormat(cur.x1)}, ${yLabel ?? 'y'} ${yFormat(cur.y0)} to ${yFormat(cur.y1)}.` +
142
+ (hl ? ` ${hlName}: ${fmtValue(hlIn)} in view.` : '')
82
143
  );
83
144
 
84
145
  // ── drawing (redraw once per frame, whatever changed)
@@ -86,7 +147,7 @@
86
147
  let raf = 0;
87
148
  const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(draw); };
88
149
  $effect(() => {
89
- void [canvas, cw, height, cur, data, hid, cellSize, markerThreshold, markerSize, m];
150
+ void [canvas, cw, H, cur, data, hl, hlHidden, highlightSize, hid, cellSize, markerThreshold, markerSize, m];
90
151
  schedule();
91
152
  return () => { cancelAnimationFrame(raf); raf = 0; };
92
153
  });
@@ -108,7 +169,7 @@
108
169
  if (!c) return;
109
170
  const ctx = c.getContext('2d')!;
110
171
  const dpr = window.devicePixelRatio;
111
- const W = cw, H = height;
172
+ const W = cw;
112
173
  if (c.width !== Math.round(W * dpr)) c.width = Math.round(W * dpr);
113
174
  if (c.height !== Math.round(H * dpr)) c.height = Math.round(H * dpr);
114
175
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -161,12 +222,12 @@
161
222
  ctx.fillStyle = cssVar(st, `--trc-s${i + 1}`, '#2a78d6');
162
223
  return ctx.fillStyle as string;
163
224
  });
164
- const { xs: dx, ys: dy, g } = data;
225
+ const { xs: dx, ys: dy, g, n: dn } = data;
165
226
  const half = markerSize / 2;
166
227
  const x0 = v.x0, x1 = v.x1, y0 = v.y0, y1 = v.y1;
167
228
  ctx.globalAlpha = 0.85;
168
229
  let last = -1;
169
- for (let i = 0; i < data.n; i++) {
230
+ for (let i = 0; i < dn; i++) {
170
231
  const x = dx[i], y = dy[i];
171
232
  if (!(x >= x0 && x <= x1 && y >= y0 && y <= y1)) continue;
172
233
  const id = g === null ? 0 : g[i];
@@ -183,19 +244,38 @@
183
244
  ctx.fillStyle = raw;
184
245
  stops.push(parseColor(ctx.fillStyle as string));
185
246
  }
186
- const ramp = stops.length > 1 ? stops : DEFAULT_RAMP.map(parseColor);
247
+ const colors = stops.length > 1 ? stops : DEFAULT_RAMP.map(parseColor);
187
248
  let max = 1;
188
249
  for (let i = 0; i < gCols * gRows; i++) if (grid[i] > max) max = grid[i];
250
+ peak = max;
251
+ const css = colors.map((c) => rampColor([c, c], 0));
252
+ if (css.join() !== ramp.join()) ramp = css;
253
+ if (maxCount !== max) maxCount = max;
189
254
  const lmax = Math.log1p(max), cw1 = pw / gCols, ch1 = ph / gRows;
190
255
  for (let r = 0; r < gRows; r++)
191
256
  for (let k = 0; k < gCols; k++) {
192
257
  const cnt = grid[r * gCols + k];
193
258
  if (!cnt) continue;
194
- ctx.fillStyle = rampColor(ramp, Math.log1p(cnt) / lmax);
259
+ ctx.fillStyle = rampColor(colors, Math.log1p(cnt) / lmax);
195
260
  ctx.fillRect(m.left + k * cw1, m.top + (gRows - 1 - r) * ch1, cw1 + 0.5, ch1 + 0.5);
196
261
  }
197
262
  }
263
+ if (hl && !hlHidden) {
264
+ const core = cssVar(st, '--tr-on-surface', '#111'), halo = cssVar(st, '--tr-surface', '#fff');
265
+ const r = highlightSize / 2;
266
+ ctx.globalAlpha = 1;
267
+ for (let i = 0; i < hl.n; i++) {
268
+ const x = hl.xs[i], y = hl.ys[i];
269
+ if (!(x >= v.x0 && x <= v.x1 && y >= v.y0 && y <= v.y1)) continue;
270
+ const px = X(x), py = Y(y);
271
+ ctx.fillStyle = halo;
272
+ ctx.beginPath(); ctx.arc(px, py, r + 2, 0, Math.PI * 2); ctx.fill();
273
+ ctx.fillStyle = core;
274
+ ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI * 2); ctx.fill();
275
+ }
276
+ }
198
277
  ctx.restore();
278
+ if (markers && maxCount !== 0) maxCount = 0;
199
279
  inView = n;
200
280
  mode = markers ? 'markers' : 'heatmap';
201
281
  }
@@ -252,14 +332,22 @@
252
332
  if (px < m.left || px > m.left + pw || py < m.top || py > m.top + ph) { hover = null; return; }
253
333
  const v = cur;
254
334
  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 };
335
+ const rx = (12 / pw) * (v.x1 - v.x0), ry = (12 / ph) * (v.y1 - v.y0);
336
+ // highlights sit on top, so they win over markers and bins
337
+ const hi = hl && !hlHidden ? nearest(hl, noHid, qx, qy, rx, ry) : -1;
338
+ const i = hi >= 0 ? hi : mode === 'markers' ? nearest(data, hid, qx, qy, rx, ry) : -1;
339
+ if (i >= 0) {
340
+ const src = hi >= 0 ? hl! : data;
341
+ const label = hi >= 0 ? highlight!.labels?.[i] : undefined;
342
+ const x = m.left + ((src.xs[i] - v.x0) / (v.x1 - v.x0)) * pw, y = m.top + ph - ((src.ys[i] - v.y0) / (v.y1 - v.y0)) * ph;
343
+ const group = hi >= 0 ? hlName : data.g ? data.names[data.g[i]] : undefined;
344
+ hover = {
345
+ x, y, head: label ?? group ?? 'Point', rows: [[xFormat(src.xs[i]), xLabel ?? 'x'], [yFormat(src.ys[i]), yLabel ?? 'y']], rx: x - 6, ry: y - 6, rw: 12, rh: 12,
346
+ item: { kind: 'point', index: i, x: src.xs[i], y: src.ys[i], group, highlight: hi >= 0, label }
347
+ };
261
348
  return;
262
349
  }
350
+ if (mode === 'markers') { hover = null; return; }
263
351
  const cw1 = pw / gCols, ch1 = ph / gRows;
264
352
  const k = Math.min(gCols - 1, Math.floor((px - m.left) / cw1)), q = Math.min(gRows - 1, Math.floor((py - m.top) / ch1));
265
353
  const cnt = grid[(gRows - 1 - q) * gCols + k];
@@ -269,7 +357,8 @@
269
357
  hover = {
270
358
  x: px, y: py, head: 'Bin',
271
359
  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
360
+ rx: m.left + k * cw1, ry: m.top + q * ch1, rw: cw1, rh: ch1,
361
+ item: { kind: 'bin', count: cnt, x0: bx, x1: bx + sx, y0: by, y1: by + sy }
273
362
  };
274
363
  }
275
364
 
@@ -302,8 +391,21 @@
302
391
  rows.sort((a, b) => b.count - a.count);
303
392
  return { n, regions: rows.length, rows: rows.slice(0, 12) };
304
393
  });
394
+ const HL_ROWS = 50;
395
+ const hlRows = $derived.by(() => {
396
+ const rows: { label: string; x: string; y: string }[] = [];
397
+ let inside = 0;
398
+ if (hl && !hlHidden)
399
+ for (let i = 0; i < hl.n; i++) {
400
+ const x = hl.xs[i], y = hl.ys[i];
401
+ if (!(x >= cur.x0 && x <= cur.x1 && y >= cur.y0 && y <= cur.y1)) continue;
402
+ if (++inside <= HL_ROWS) rows.push({ label: highlight!.labels?.[i] ?? `${hlName} ${i + 1}`, x: xFormat(x), y: yFormat(y) });
403
+ }
404
+ return { inside, rows };
405
+ });
305
406
  </script>
306
407
 
408
+ {#snippet chart()}
307
409
  <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
308
410
  {#snippet plot(w)}
309
411
  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
@@ -314,7 +416,7 @@
314
416
  onpointerleave={() => (hover = null)} onblur={() => (hover = null)} onkeydown={key}
315
417
  ondblclick={(e) => { const r = box!.getBoundingClientRect(); zoomAt(e.clientX - r.left, e.clientY - r.top, 2); }}
316
418
  >
317
- <canvas bind:this={canvas} style:height="{height}px" aria-hidden="true" data-mode={mode || undefined}></canvas>
419
+ <canvas bind:this={canvas} style:height="{H}px" aria-hidden="true" data-mode={mode || undefined}></canvas>
318
420
  {#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
421
  </div>
320
422
  <button type="button" class="reset" disabled={zoom === null} onclick={reset}>Reset view</button>
@@ -322,12 +424,20 @@
322
424
 
323
425
  {#if hover}
324
426
  <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}
427
+ {#if tooltip}
428
+ {@render tooltip(hover.item)}
429
+ {:else}
430
+ <div class="head">{hover.head}</div>
431
+ {#each hover.rows as [val, name]}
432
+ <div class="row"><b>{val}</b><span class="name">{name}</span></div>
433
+ {/each}
434
+ {/if}
329
435
  </Tooltip>
330
436
  {/if}
437
+
438
+ {#if colorKey && mode === 'heatmap'}
439
+ <div class="key"><ColorScale caption="Points per cell, log scale" low="1" mid={fmtValue(Math.max(1, Math.round(Math.sqrt(peak + 1) - 1)))} high={fmtValue(peak)} size={180} /></div>
440
+ {/if}
331
441
  {/snippet}
332
442
 
333
443
  {#snippet table()}
@@ -342,8 +452,28 @@
342
452
  {/each}
343
453
  </tbody>
344
454
  </table>
455
+ {#if highlight}
456
+ <table class="hl">
457
+ <caption>{hlName}: {hlHidden ? 'hidden' : `${fmtValue(hlRows.inside)} in view${hlRows.inside > hlRows.rows.length ? ` (first ${hlRows.rows.length} shown)` : ''}`}</caption>
458
+ <thead><tr><th>Point</th><th>{xLabel ?? 'X'}</th><th>{yLabel ?? 'Y'}</th></tr></thead>
459
+ <tbody>
460
+ {#each hlRows.rows as r}
461
+ <tr><td>{r.label}</td><td>{r.x}</td><td>{r.y}</td></tr>
462
+ {:else}
463
+ <tr><td colspan="3">No highlighted points in view</td></tr>
464
+ {/each}
465
+ </tbody>
466
+ </table>
467
+ {/if}
345
468
  {/snippet}
346
469
  </ChartFrame>
470
+ {/snippet}
471
+
472
+ {#if fill}
473
+ <div class="fillwrap" bind:this={wrap}>{@render chart()}</div>
474
+ {:else}
475
+ {@render chart()}
476
+ {/if}
347
477
 
348
478
  <style>
349
479
  .dc { position: relative; touch-action: none; cursor: grab; outline: none; user-select: none; }
@@ -355,6 +485,9 @@
355
485
  .reset:hover:not(:disabled) { background: var(--tr-surface-container-high); }
356
486
  .reset:disabled { opacity: 0.45; cursor: default; }
357
487
  .reset:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
488
+ .key { margin-top: 6px; }
489
+ .fillwrap { height: 100%; min-height: 0; overflow: hidden; }
490
+ .hl { margin-top: 12px; }
358
491
  .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
359
492
  caption { caption-side: top; padding: 4px 8px; text-align: left; color: var(--tr-on-surface-variant); }
360
493
  .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
@@ -1,4 +1,5 @@
1
- import { type DensityPoint } from './density.js';
1
+ import type { Snippet } from 'svelte';
2
+ import { type DensityHighlight, type DensityHover, type DensityPoint } from './density.js';
2
3
  type $$ComponentProps = {
3
4
  /** Object input, convenient up to ~10^5 points. `group` picks the palette colour of markers and a legend entry (max 8). */
4
5
  points?: DensityPoint[];
@@ -10,7 +11,12 @@ type $$ComponentProps = {
10
11
  groupNames?: string[];
11
12
  title?: string;
12
13
  subtitle?: string;
13
- height?: number;
14
+ /**
15
+ * Plot height in px (default 320), or `'fill'` to size to the container's height. `'fill'` measures the
16
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
17
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
18
+ */
19
+ height?: number | 'fill';
14
20
  xLabel?: string;
15
21
  yLabel?: string;
16
22
  xFormat?: (n: number) => string;
@@ -24,7 +30,24 @@ type $$ComponentProps = {
24
30
  markerThreshold?: number;
25
31
  /** Marker edge in CSS px. */
26
32
  markerSize?: number;
33
+ /**
34
+ * A small set of notable points that stay drawn as markers on top of the heatmap at every zoom, with their
35
+ * own legend entry (toggleable), tooltip hit-testing and a table section. Pass them separately from `points` /
36
+ * `xs`: highlights are not part of the bin counts, so do not repeat them in the main data. Cost is linear in
37
+ * the highlight count per frame, so keep the set small (thousands, not 10^5).
38
+ */
39
+ highlight?: DensityHighlight;
40
+ /** Highlight marker diameter in CSS px (a surface-coloured halo is added around it). */
41
+ highlightSize?: number;
42
+ /** Replaces the default tooltip body. Receives the hovered point (index, x, y, group, highlight flag) or bin (count and ranges). */
43
+ tooltip?: Snippet<[item: DensityHover]>;
44
+ /** Show a colour key (points per cell, 1 to the busiest cell in view) under the plot while in heatmap mode. */
45
+ colorKey?: boolean;
46
+ /** Bindable: the busiest cell's point count in the current view (heatmap mode; 0 while drawing markers). Cells are coloured on a log scale from 1 to this value. */
47
+ maxCount?: number;
48
+ /** Bindable: the resolved heatmap ramp, low to high, as CSS colours (from `--trc-seq-*` or the built-in fallback). */
49
+ ramp?: string[];
27
50
  };
28
- declare const DensityChart: import("svelte").Component<$$ComponentProps, {}, "">;
51
+ declare const DensityChart: import("svelte").Component<$$ComponentProps, {}, "maxCount" | "ramp">;
29
52
  type DensityChart = ReturnType<typeof DensityChart>;
30
53
  export default DensityChart;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import ChartFrame from './ChartFrame.svelte';
3
+ import { watchFill } from './fill.js';
3
4
  import { fmtValue, seriesColor, str, MAX_SERIES } from './util.js';
4
5
 
5
6
  let {
@@ -7,13 +8,22 @@
7
8
  title,
8
9
  subtitle,
9
10
  size = 200,
11
+ height,
10
12
  format = fmtValue,
11
13
  centerLabel = 'Total'
12
14
  }: {
13
15
  slices: { name: string; value: number }[];
14
16
  title?: string;
15
17
  subtitle?: string;
18
+ /** Donut diameter in px (default 200). Capped to the available width when `height` is `'fill'`. */
16
19
  size?: number;
20
+ /**
21
+ * Overrides `size`: a number sets the diameter in px, or `'fill'` sizes the donut to the container's height
22
+ * (and never wider than the container). `'fill'` measures the parent with a ResizeObserver, so the parent
23
+ * needs a definite height (e.g. `height: 360px`, or a grid/flex track with a fixed size). A parent with
24
+ * `height: auto` collapses to the 120px minimum.
25
+ */
26
+ height?: number | 'fill';
17
27
  format?: (n: number) => string;
18
28
  centerLabel?: string;
19
29
  } = $props();
@@ -34,9 +44,20 @@
34
44
  const items = $derived(base.map((s) => ({ name: s.name, color: s.color, hidden: hidden.includes(s.name) })));
35
45
  const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
36
46
 
37
- const r = $derived(size / 2 - 6);
38
- const th = $derived(Math.max(14, size * 0.14));
39
- const arcs = $derived.by(() => {
47
+ // height='fill': the svg = container height minus the frame's own chrome (title, legend, padding)
48
+ let wrap = $state<HTMLElement>();
49
+ let fillH = $state(200);
50
+ const fill = $derived(height === 'fill');
51
+ $effect(() => {
52
+ if (!fill || !wrap) return;
53
+ return watchFill(wrap, 'svg', (h) => (fillH = h));
54
+ });
55
+ const diameter = (w: number) => (height === 'fill' ? Math.max(120, Math.min(fillH, w)) : (height ?? size));
56
+
57
+ // geometry for a diameter of `size` px; built per render because 'fill' also depends on the available width
58
+ const arcsFor = (size: number) => {
59
+ const r = size / 2 - 6;
60
+ const th = Math.max(14, size * 0.14);
40
61
  let a0 = -Math.PI / 2;
41
62
  return vis.map((s) => {
42
63
  const sweep = total ? (s.value / total) * Math.PI * 2 : 0;
@@ -50,7 +71,7 @@
50
71
  a0 = a1;
51
72
  return { ...s, d };
52
73
  });
53
- });
74
+ };
54
75
  const focus = $derived(vis.find((s) => s.name === active));
55
76
  /** Share of the visible total; 0 (not NaN) when everything visible is 0. */
56
77
  const pct = (v: number) => (total ? Math.round((v / total) * 100) : 0);
@@ -58,8 +79,11 @@
58
79
  const sliceLabel = (a: { name: string; value: number }) => `${a.name}: ${str(format(a.value))} (${pct(a.value)}%)`;
59
80
  </script>
60
81
 
82
+ {#snippet chart()}
61
83
  <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
62
- {#snippet plot()}
84
+ {#snippet plot(w)}
85
+ {@const size = diameter(w)}
86
+ {@const arcs = arcsFor(size)}
63
87
  <div class="wrap">
64
88
  <svg width={size} height={size} role="img" aria-label="{title ?? 'Donut chart'}. A table view is available.">
65
89
  {#each arcs as a (a.name)}
@@ -88,8 +112,16 @@
88
112
  </table>
89
113
  {/snippet}
90
114
  </ChartFrame>
115
+ {/snippet}
116
+
117
+ {#if fill}
118
+ <div class="fillwrap" bind:this={wrap}>{@render chart()}</div>
119
+ {:else}
120
+ {@render chart()}
121
+ {/if}
91
122
 
92
123
  <style>
124
+ .fillwrap { height: 100%; min-height: 0; overflow: hidden; }
93
125
  .wrap { display: flex; justify-content: center; padding: 8px 0; }
94
126
  /* 2px surface gap between adjacent fills */
95
127
  .slice { stroke: var(--tr-surface-container-low); stroke-width: 2; outline: none; transition: opacity var(--tr-dur-fast) var(--tr-ease); }
@@ -5,7 +5,15 @@ type $$ComponentProps = {
5
5
  }[];
6
6
  title?: string;
7
7
  subtitle?: string;
8
+ /** Donut diameter in px (default 200). Capped to the available width when `height` is `'fill'`. */
8
9
  size?: number;
10
+ /**
11
+ * Overrides `size`: a number sets the diameter in px, or `'fill'` sizes the donut to the container's height
12
+ * (and never wider than the container). `'fill'` measures the parent with a ResizeObserver, so the parent
13
+ * needs a definite height (e.g. `height: 360px`, or a grid/flex track with a fixed size). A parent with
14
+ * `height: auto` collapses to the 120px minimum.
15
+ */
16
+ height?: number | 'fill';
9
17
  format?: (n: number) => string;
10
18
  centerLabel?: string;
11
19
  };
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import ChartFrame from './ChartFrame.svelte';
3
+ import { watchFill } from './fill.js';
3
4
  import Tooltip from './Tooltip.svelte';
4
5
  import { barPath, fmtAxis, fmtValue, linear, niceTicks, seriesColor, str } from './util.js';
5
6
 
@@ -21,7 +22,12 @@
21
22
  domain?: [number, number];
22
23
  title?: string;
23
24
  subtitle?: string;
24
- height?: number;
25
+ /**
26
+ * Plot height in px (default 240), or `'fill'` to size to the container's height. `'fill'` measures the
27
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
28
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
29
+ */
30
+ height?: number | 'fill';
25
31
  color?: string;
26
32
  xFormat?: (n: number) => string;
27
33
  /** Brushed selection [from, to] on the value axis (snaps to bin edges). Drag to select, click to clear. */
@@ -61,10 +67,22 @@
61
67
  const span = (i: number) => `${str(xFormat(edges[i]))} – ${str(xFormat(edges[i + 1]))}`;
62
68
  const snapLo = (v: number) => grid.min + Math.floor((v - grid.min) / step + 1e-9) * step;
63
69
  const snapHi = (v: number) => grid.min + Math.ceil((v - grid.min) / step - 1e-9) * step;
70
+
71
+ // height='fill': plot = container height minus the frame's own chrome (title, legend, key, padding)
72
+ let wrap = $state<HTMLElement>();
73
+ let fillH = $state(240);
74
+ const fill = $derived(height === 'fill');
75
+ const H = $derived(height === 'fill' ? fillH : height);
76
+ $effect(() => {
77
+ if (!fill || !wrap) return;
78
+ return watchFill(wrap, 'svg', (h) => (fillH = h));
79
+ });
64
80
  </script>
65
81
 
82
+ {#snippet chart()}
66
83
  <ChartFrame {title} {subtitle}>
67
84
  {#snippet plot(w)}
85
+ {@const height = H}
68
86
  {@const m = { top: 12, right: 12, bottom: 28, left: 46 }}
69
87
  {@const X = linear(grid.min, grid.max, m.left, w - m.right)}
70
88
  {@const Y = linear(yScale.min, yScale.max, height - m.bottom, m.top)}
@@ -147,8 +165,16 @@
147
165
  </table>
148
166
  {/snippet}
149
167
  </ChartFrame>
168
+ {/snippet}
169
+
170
+ {#if fill}
171
+ <div class="fillwrap" bind:this={wrap}>{@render chart()}</div>
172
+ {:else}
173
+ {@render chart()}
174
+ {/if}
150
175
 
151
176
  <style>
177
+ .fillwrap { height: 100%; min-height: 0; overflow: hidden; }
152
178
  svg { display: block; outline: none; overflow: visible; }
153
179
  svg:focus-visible { border-radius: var(--tr-radius-sm); box-shadow: var(--tr-focus-ring); }
154
180
  .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; }
@@ -5,7 +5,12 @@ type $$ComponentProps = {
5
5
  domain?: [number, number];
6
6
  title?: string;
7
7
  subtitle?: string;
8
- height?: number;
8
+ /**
9
+ * Plot height in px (default 240), or `'fill'` to size to the container's height. `'fill'` measures the
10
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
11
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
12
+ */
13
+ height?: number | 'fill';
9
14
  color?: string;
10
15
  xFormat?: (n: number) => string;
11
16
  /** Brushed selection [from, to] on the value axis (snaps to bin edges). Drag to select, click to clear. */
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import ChartFrame from './ChartFrame.svelte';
3
3
  import Tooltip from './Tooltip.svelte';
4
+ import { watchFill } from './fill.js';
4
5
  import type { Series, Band, ReferenceLine } from './types.js';
5
6
  import { fmtAxis, fmtValue, fmtX, isPos, linear, logLinear, logTicks, niceTicks, seriesColor, str, spreadLabels, MAX_SERIES } from './util.js';
6
7
 
@@ -96,16 +97,7 @@
96
97
  const H = $derived(height === 'fill' ? fillH : height);
97
98
  $effect(() => {
98
99
  if (!fill || !wrap) return;
99
- const el = wrap;
100
- const measure = () => {
101
- const fig = el.querySelector('figure'), svg = el.querySelector('svg[role=group]');
102
- if (!fig || !svg) return;
103
- fillH = Math.max(120, Math.floor(el.clientHeight - (fig.getBoundingClientRect().height - svg.getBoundingClientRect().height)));
104
- };
105
- const ro = new ResizeObserver(measure);
106
- ro.observe(el);
107
- measure();
108
- return () => ro.disconnect();
100
+ return watchFill(wrap, 'svg[role=group]', (h) => (fillH = h));
109
101
  });
110
102
  /** Per visible series: [lo, hi] band at each x (stacked), else lo = 0, hi = value. */
111
103
  const bands = $derived.by(() => {
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import ChartFrame from './ChartFrame.svelte';
3
+ import { watchFill } from './fill.js';
3
4
  import Tooltip from './Tooltip.svelte';
4
5
  import type { ScatterPoint } from './types.js';
5
6
  import { fmtAxis, fmtValue, linear, niceTicks, seriesColor, MAX_SERIES } from './util.js';
@@ -21,7 +22,12 @@
21
22
  points: ScatterPoint[];
22
23
  title?: string;
23
24
  subtitle?: string;
24
- height?: number;
25
+ /**
26
+ * Plot height in px (default 280), or `'fill'` to size to the container's height. `'fill'` measures the
27
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
28
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
29
+ */
30
+ height?: number | 'fill';
25
31
  xLabel?: string;
26
32
  yLabel?: string;
27
33
  /** Name of the size dimension in tooltips and the table. */
@@ -63,10 +69,22 @@
63
69
  const cur = $derived(active != null ? (vis[active] ?? null) : null);
64
70
  const readout = (p: (typeof vis)[number]) =>
65
71
  `${p.label ?? 'Point'}${p.group ? `, ${p.group}` : ''}: ${xLabel ?? 'x'} ${xFormat(p.x)}, ${yLabel ?? 'y'} ${yFormat(p.y)}${p.size != null ? `, ${sizeLabel.toLowerCase()} ${sizeFormat(p.size)}` : ''}`;
72
+
73
+ // height='fill': plot = container height minus the frame's own chrome (title, legend, key, padding)
74
+ let wrap = $state<HTMLElement>();
75
+ let fillH = $state(280);
76
+ const fill = $derived(height === 'fill');
77
+ const H = $derived(height === 'fill' ? fillH : height);
78
+ $effect(() => {
79
+ if (!fill || !wrap) return;
80
+ return watchFill(wrap, 'svg[role=group]', (h) => (fillH = h));
81
+ });
66
82
  </script>
67
83
 
84
+ {#snippet chart()}
68
85
  <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
69
86
  {#snippet plot(w)}
87
+ {@const height = H}
70
88
  {@const m = { top: 14, right: 16, bottom: xLabel ? 44 : 28, left: yLabel ? 62 : 48 }}
71
89
  {@const X = linear(xScale.min, xScale.max, m.left, w - m.right)}
72
90
  {@const Y = linear(yScale.min, yScale.max, height - m.bottom, m.top)}
@@ -136,8 +154,16 @@
136
154
  </table>
137
155
  {/snippet}
138
156
  </ChartFrame>
157
+ {/snippet}
158
+
159
+ {#if fill}
160
+ <div class="fillwrap" bind:this={wrap}>{@render chart()}</div>
161
+ {:else}
162
+ {@render chart()}
163
+ {/if}
139
164
 
140
165
  <style>
166
+ .fillwrap { height: 100%; min-height: 0; overflow: hidden; }
141
167
  svg { display: block; outline: none; overflow: visible; }
142
168
  svg:focus-visible { border-radius: var(--tr-radius-sm); box-shadow: var(--tr-focus-ring); }
143
169
  .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; stroke-width: 1; }
@@ -4,7 +4,12 @@ type $$ComponentProps = {
4
4
  points: ScatterPoint[];
5
5
  title?: string;
6
6
  subtitle?: string;
7
- height?: number;
7
+ /**
8
+ * Plot height in px (default 280), or `'fill'` to size to the container's height. `'fill'` measures the
9
+ * parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
10
+ * track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
11
+ */
12
+ height?: number | 'fill';
8
13
  xLabel?: string;
9
14
  yLabel?: string;
10
15
  /** Name of the size dimension in tooltips and the table. */
package/dist/density.d.ts CHANGED
@@ -24,6 +24,40 @@ export type Prepared = {
24
24
  * Groups beyond the 8 palette slots, and points without a group in a grouped set, take slot 0.
25
25
  */
26
26
  export declare function prepare(points: DensityPoint[] | undefined, xs: ArrayLike<number> | undefined, ys: ArrayLike<number> | undefined, groupIds?: Uint8Array, groupNames?: string[]): Prepared;
27
+ /** Finite extent of the first `n` pairs, or `null` when none is finite. */
28
+ export declare function extentOf(px: ArrayLike<number>, py: ArrayLike<number>, n: number): View | null;
29
+ /** Smallest extent holding both (either may be `null`). */
30
+ export declare function mergeExtent(a: View | null, b: View | null): View | null;
31
+ /** What a custom `tooltip` snippet receives. */
32
+ export type DensityHover = {
33
+ kind: 'point';
34
+ /** Index into the `points` / `xs` array, or into `highlight.xs` when `highlight` is true. */
35
+ index: number;
36
+ x: number;
37
+ y: number;
38
+ /** Group name (or the highlight name for a highlight). */
39
+ group?: string;
40
+ /** True for a point from the `highlight` set. */
41
+ highlight: boolean;
42
+ /** Per-point label of a highlight, when supplied. */
43
+ label?: string;
44
+ } | {
45
+ kind: 'bin';
46
+ count: number;
47
+ x0: number;
48
+ x1: number;
49
+ y0: number;
50
+ y1: number;
51
+ };
52
+ /** A small set of notable points drawn as markers on top of the heatmap at every zoom. */
53
+ export type DensityHighlight = {
54
+ xs: ArrayLike<number>;
55
+ ys: ArrayLike<number>;
56
+ /** Legend entry, default "Highlighted". */
57
+ name?: string;
58
+ /** Optional text per point, shown in the tooltip and the highlights table. */
59
+ labels?: ArrayLike<string>;
60
+ };
27
61
  /**
28
62
  * Counts the points inside `v` into `out` (cols x rows, row 0 at the bottom, y up) in one pass, skipping hidden
29
63
  * groups and non-finite values. Returns how many points fell inside the view. No allocation.
package/dist/density.js CHANGED
@@ -45,6 +45,10 @@ export function prepare(points, xs, ys, groupIds, groupNames) {
45
45
  names = [...ids.keys()];
46
46
  }
47
47
  const n = Math.min(px.length, py.length);
48
+ return { xs: px, ys: py, g, names, n, extent: extentOf(px, py, n) };
49
+ }
50
+ /** Finite extent of the first `n` pairs, or `null` when none is finite. */
51
+ export function extentOf(px, py, n) {
48
52
  let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
49
53
  for (let i = 0; i < n; i++) {
50
54
  const x = px[i], y = py[i];
@@ -59,7 +63,13 @@ export function prepare(points, xs, ys, groupIds, groupNames) {
59
63
  if (y > y1)
60
64
  y1 = y;
61
65
  }
62
- return { xs: px, ys: py, g, names, n, extent: x0 <= x1 ? { x0, x1, y0, y1 } : null };
66
+ return x0 <= x1 ? { x0, x1, y0, y1 } : null;
67
+ }
68
+ /** Smallest extent holding both (either may be `null`). */
69
+ export function mergeExtent(a, b) {
70
+ if (!a || !b)
71
+ return a ?? b;
72
+ return { x0: Math.min(a.x0, b.x0), x1: Math.max(a.x1, b.x1), y0: Math.min(a.y0, b.y0), y1: Math.max(a.y1, b.y1) };
63
73
  }
64
74
  /**
65
75
  * Counts the points inside `v` into `out` (cols x rows, row 0 at the bottom, y up) in one pass, skipping hidden
package/dist/fill.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared `height="fill"` measuring for the charts. Observes `el` (the fill wrapper) and reports the plot height:
3
+ * the wrapper's height minus the frame's own chrome (title, legend, key, padding), found by comparing the
4
+ * `<figure>` with the plot element matched by `probe`. The result is floored at 120px.
5
+ * Returns a disposer that disconnects the observer.
6
+ */
7
+ export declare function watchFill(el: HTMLElement, probe: string, onHeight: (h: number) => void): () => void;
package/dist/fill.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared `height="fill"` measuring for the charts. Observes `el` (the fill wrapper) and reports the plot height:
3
+ * the wrapper's height minus the frame's own chrome (title, legend, key, padding), found by comparing the
4
+ * `<figure>` with the plot element matched by `probe`. The result is floored at 120px.
5
+ * Returns a disposer that disconnects the observer.
6
+ */
7
+ export function watchFill(el, probe, onHeight) {
8
+ const measure = () => {
9
+ const fig = el.querySelector('figure'), plot = el.querySelector(probe);
10
+ if (!fig || !plot)
11
+ return;
12
+ onHeight(Math.max(120, Math.floor(el.clientHeight - (fig.getBoundingClientRect().height - plot.getBoundingClientRect().height))));
13
+ };
14
+ const ro = new ResizeObserver(measure);
15
+ ro.observe(el);
16
+ measure();
17
+ return () => ro.disconnect();
18
+ }
package/dist/index.d.ts CHANGED
@@ -10,4 +10,4 @@ export { default as ColorScale } from './ColorScale.svelte';
10
10
  export { seriesColor, niceTicks, linear, gradientFrom } from './util.js';
11
11
  export type { ScaleKind } from './util.js';
12
12
  export type { Series, ScatterPoint, Band, ReferenceLine } from './types.js';
13
- export type { DensityPoint } from './density.js';
13
+ export type { DensityPoint, DensityHighlight, DensityHover } from './density.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruckc/truss-chart",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Zero-dependency SVG charts for Truss UI",
5
5
  "type": "module",
6
6
  "license": "MIT",