@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.
- package/dist/BarChart.svelte +27 -2
- package/dist/BarChart.svelte.d.ts +6 -2
- package/dist/ColorScale.svelte +20 -1
- package/dist/ColorScale.svelte.d.ts +11 -0
- package/dist/DensityChart.svelte +163 -30
- package/dist/DensityChart.svelte.d.ts +26 -3
- package/dist/DonutChart.svelte +37 -5
- package/dist/DonutChart.svelte.d.ts +8 -0
- package/dist/Histogram.svelte +27 -1
- package/dist/Histogram.svelte.d.ts +6 -1
- package/dist/LineChart.svelte +2 -10
- package/dist/ScatterChart.svelte +27 -1
- package/dist/ScatterChart.svelte.d.ts +6 -1
- package/dist/density.d.ts +34 -0
- package/dist/density.js +11 -1
- package/dist/fill.d.ts +7 -0
- package/dist/fill.js +18 -0
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
package/dist/BarChart.svelte
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
-
/**
|
|
13
|
-
|
|
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. */
|
package/dist/ColorScale.svelte
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/DensityChart.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
|
81
|
+
const full = $derived(prepare(points, xs, ys, groupIds, groupNames));
|
|
51
82
|
let hidden = $state<string[]>([]);
|
|
52
|
-
const
|
|
53
|
-
const
|
|
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 =
|
|
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
|
|
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
|
|
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,
|
|
77
|
-
const total = $derived(
|
|
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,
|
|
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
|
|
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 <
|
|
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
|
|
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(
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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="{
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/dist/DonutChart.svelte
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
};
|
package/dist/Histogram.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|
package/dist/LineChart.svelte
CHANGED
|
@@ -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
|
-
|
|
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(() => {
|
package/dist/ScatterChart.svelte
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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';
|