@ruckc/truss-chart 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BarChart.svelte +49 -10
- package/dist/BarChart.svelte.d.ts +8 -0
- package/dist/ColorScale.svelte +59 -0
- package/dist/ColorScale.svelte.d.ts +22 -0
- package/dist/DensityChart.svelte +364 -0
- package/dist/DensityChart.svelte.d.ts +30 -0
- package/dist/DonutChart.svelte +5 -3
- package/dist/Histogram.svelte +7 -4
- package/dist/LineChart.svelte +137 -29
- package/dist/LineChart.svelte.d.ts +30 -2
- package/dist/StatTile.svelte +13 -6
- package/dist/StatTile.svelte.d.ts +2 -0
- package/dist/charts.css +28 -0
- package/dist/density.d.ts +44 -0
- package/dist/density.js +149 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/types.d.ts +12 -0
- package/dist/util.d.ts +15 -0
- package/dist/util.js +25 -0
- package/package.json +1 -1
package/dist/DonutChart.svelte
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import ChartFrame from './ChartFrame.svelte';
|
|
3
|
-
import { fmtValue, seriesColor, MAX_SERIES } from './util.js';
|
|
3
|
+
import { fmtValue, seriesColor, str, MAX_SERIES } from './util.js';
|
|
4
4
|
|
|
5
5
|
let {
|
|
6
6
|
slices,
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
const focus = $derived(vis.find((s) => s.name === active));
|
|
55
55
|
/** Share of the visible total; 0 (not NaN) when everything visible is 0. */
|
|
56
56
|
const pct = (v: number) => (total ? Math.round((v / total) * 100) : 0);
|
|
57
|
+
// built in script: interpolating in markup would add unreachable `?? ''` branches
|
|
58
|
+
const sliceLabel = (a: { name: string; value: number }) => `${a.name}: ${str(format(a.value))} (${pct(a.value)}%)`;
|
|
57
59
|
</script>
|
|
58
60
|
|
|
59
61
|
<ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
|
|
@@ -64,7 +66,7 @@
|
|
|
64
66
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
65
67
|
<path
|
|
66
68
|
role="img" d={a.d} fill={a.color} fill-rule="evenodd" class="slice" data-dim={active != null && active !== a.name} tabindex="0"
|
|
67
|
-
aria-label=
|
|
69
|
+
aria-label={sliceLabel(a)}
|
|
68
70
|
onpointerenter={() => (active = a.name)} onpointerleave={() => (active = null)}
|
|
69
71
|
onfocus={() => (active = a.name)} onblur={() => (active = null)}
|
|
70
72
|
/>
|
|
@@ -81,7 +83,7 @@
|
|
|
81
83
|
<table>
|
|
82
84
|
<thead><tr><th>Slice</th><th>Value</th><th>Share</th></tr></thead>
|
|
83
85
|
<tbody>
|
|
84
|
-
{#each base as s}<tr><td>{s.name}</td><td>{format(s.value)}</td><td>{Math.round((s.value / (base.reduce((a, q) => a + q.value, 0) || 1)) * 100)}
|
|
86
|
+
{#each base as s}<tr><td>{s.name}</td><td>{format(s.value)}</td><td>{`${Math.round((s.value / (base.reduce((a, q) => a + q.value, 0) || 1)) * 100)}%`}</td></tr>{/each}
|
|
85
87
|
</tbody>
|
|
86
88
|
</table>
|
|
87
89
|
{/snippet}
|
package/dist/Histogram.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import ChartFrame from './ChartFrame.svelte';
|
|
3
3
|
import Tooltip from './Tooltip.svelte';
|
|
4
|
-
import { barPath, fmtAxis, fmtValue, linear, niceTicks, seriesColor } from './util.js';
|
|
4
|
+
import { barPath, fmtAxis, fmtValue, linear, niceTicks, seriesColor, str } from './util.js';
|
|
5
5
|
|
|
6
6
|
let {
|
|
7
7
|
values,
|
|
@@ -56,6 +56,9 @@
|
|
|
56
56
|
range = r;
|
|
57
57
|
onbrush?.(r);
|
|
58
58
|
}
|
|
59
|
+
// built in script: interpolating in markup would add unreachable `?? ''` branches
|
|
60
|
+
const share = (c: number) => (total ? Math.round((c / total) * 100) : 0);
|
|
61
|
+
const span = (i: number) => `${str(xFormat(edges[i]))} – ${str(xFormat(edges[i + 1]))}`;
|
|
59
62
|
const snapLo = (v: number) => grid.min + Math.floor((v - grid.min) / step + 1e-9) * step;
|
|
60
63
|
const snapHi = (v: number) => grid.min + Math.ceil((v - grid.min) / step - 1e-9) * step;
|
|
61
64
|
</script>
|
|
@@ -127,8 +130,8 @@
|
|
|
127
130
|
|
|
128
131
|
{#if hover != null && !drag}
|
|
129
132
|
<Tooltip x={X(edges[hover] + step / 2)} y={Y(counts[hover]) - 4} width={w}>
|
|
130
|
-
<div class="head">{
|
|
131
|
-
<div class="row"><b>{counts[hover]}</b><span class="name">{
|
|
133
|
+
<div class="head">{span(hover)}</div>
|
|
134
|
+
<div class="row"><b>{counts[hover]}</b><span class="name">{`${share(counts[hover])}% of ${total}`}</span></div>
|
|
132
135
|
</Tooltip>
|
|
133
136
|
{/if}
|
|
134
137
|
{/snippet}
|
|
@@ -138,7 +141,7 @@
|
|
|
138
141
|
<thead><tr><th>Range</th><th>Count</th><th>Share</th></tr></thead>
|
|
139
142
|
<tbody>
|
|
140
143
|
{#each counts as c, i}
|
|
141
|
-
<tr><td>{
|
|
144
|
+
<tr><td>{span(i)}</td><td>{c}</td><td>{`${share(c)}%`}</td></tr>
|
|
142
145
|
{/each}
|
|
143
146
|
</tbody>
|
|
144
147
|
</table>
|
package/dist/LineChart.svelte
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import ChartFrame from './ChartFrame.svelte';
|
|
3
3
|
import Tooltip from './Tooltip.svelte';
|
|
4
|
-
import type { Series } from './types.js';
|
|
5
|
-
import { fmtAxis, fmtValue, fmtX, linear, niceTicks, seriesColor, spreadLabels, MAX_SERIES } from './util.js';
|
|
4
|
+
import type { Series, Band, ReferenceLine } from './types.js';
|
|
5
|
+
import { fmtAxis, fmtValue, fmtX, isPos, linear, logLinear, logTicks, niceTicks, seriesColor, str, spreadLabels, MAX_SERIES } from './util.js';
|
|
6
6
|
|
|
7
7
|
let {
|
|
8
8
|
x,
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
stacked = false,
|
|
15
15
|
zeroBaseline = false,
|
|
16
16
|
directLabels = true,
|
|
17
|
-
yFormat = fmtValue
|
|
17
|
+
yFormat = fmtValue,
|
|
18
|
+
xFormat,
|
|
19
|
+
band,
|
|
20
|
+
referenceLines = [],
|
|
21
|
+
xScale = 'linear',
|
|
22
|
+
yScale: yScaleKind = 'linear'
|
|
18
23
|
}: {
|
|
19
24
|
/** Shared x positions: numbers or Dates. */
|
|
20
25
|
x: (number | Date)[];
|
|
@@ -22,25 +27,86 @@
|
|
|
22
27
|
series: Series[];
|
|
23
28
|
title?: string;
|
|
24
29
|
subtitle?: string;
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Plot height in px (default 240), or `'fill'` to size to the container's height. `'fill'` measures the
|
|
32
|
+
* parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
|
|
33
|
+
* track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
|
|
34
|
+
*/
|
|
35
|
+
height?: number | 'fill';
|
|
26
36
|
area?: boolean;
|
|
27
37
|
/** Only meaningful with `area`. `true` stacks the series; `'percent'` normalises each x to 100%. Missing values count as 0 in a stack; tooltips and the table show the raw values. */
|
|
28
38
|
stacked?: boolean | 'percent';
|
|
39
|
+
/** Ensures 0 is inside the y-domain. It does not draw a line at 0: add `referenceLines={[{ value: 0 }]}` for that. */
|
|
29
40
|
zeroBaseline?: boolean;
|
|
30
41
|
/** Name labels at line ends when ≤ 4 series. */
|
|
31
42
|
directLabels?: boolean;
|
|
32
43
|
yFormat?: (n: number) => string;
|
|
44
|
+
/** Formats x values (a Date when `x` holds Dates) for axis ticks, tooltip and table. Default: number or date-aware short format. */
|
|
45
|
+
xFormat?: (x: number | Date) => string;
|
|
46
|
+
/**
|
|
47
|
+
* A chart-level shaded envelope drawn behind the lines (e.g. a confidence or min/max range). `lo` and `hi` align
|
|
48
|
+
* with `x` by index; `null` in either leaves a gap. It widens the y-domain, appears in the tooltip and table as
|
|
49
|
+
* `lo – hi` under `label` (default "Band"), and uses `color` (default: the first series' colour).
|
|
50
|
+
* Ignored when `stacked` is on.
|
|
51
|
+
*/
|
|
52
|
+
band?: Band;
|
|
53
|
+
/** Emphasised, labelled horizontal lines. Their values widen the y-domain; they are also announced in the chart's accessible name and listed under the table view. */
|
|
54
|
+
referenceLines?: ReferenceLine[];
|
|
55
|
+
/**
|
|
56
|
+
* `'log'` plots x on a base-10 log axis with ticks at powers of 10. Points with x <= 0 are dropped (not drawn,
|
|
57
|
+
* not in the tooltip or table). Ignored for Date x values.
|
|
58
|
+
*/
|
|
59
|
+
xScale?: 'linear' | 'log';
|
|
60
|
+
/**
|
|
61
|
+
* `'log'` plots y on a base-10 log axis with ticks at powers of 10. Values <= 0 (including band and
|
|
62
|
+
* reference values) are treated as missing and shown as an em dash; area fills run down to the axis
|
|
63
|
+
* minimum. `stacked` is ignored (series draw independently).
|
|
64
|
+
*/
|
|
65
|
+
yScale?: 'linear' | 'log';
|
|
33
66
|
} = $props();
|
|
34
67
|
|
|
35
68
|
let hidden = $state<string[]>([]);
|
|
36
69
|
let hover = $state<number | null>(null);
|
|
37
70
|
|
|
38
|
-
const all = $derived(series.slice(0, MAX_SERIES).map((s, i) => ({ ...s, i, color: s.color ?? seriesColor(i) })));
|
|
39
|
-
const vis = $derived(all.filter((s) => !hidden.includes(s.name)));
|
|
40
71
|
const isDate = $derived(x[0] instanceof Date);
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
72
|
+
const xLog = $derived(xScale === 'log' && !isDate);
|
|
73
|
+
const yLog = $derived(yScaleKind === 'log');
|
|
74
|
+
/** Indices of x kept after dropping non-positive x on a log axis. */
|
|
75
|
+
const ix = $derived(x.map((_, i) => i).filter((i) => !xLog || +x[i] > 0));
|
|
76
|
+
const clean = (v: number | null | undefined) => (v == null || (yLog && !isPos(v)) ? null : v);
|
|
77
|
+
const pick = (vals: (number | null)[]) => ix.map((i) => clean(vals[i]));
|
|
78
|
+
const all = $derived(series.slice(0, MAX_SERIES).map((s, i) => ({ ...s, i, color: s.color ?? seriesColor(i), values: pick(s.values) })));
|
|
79
|
+
const vis = $derived(all.filter((s) => !hidden.includes(s.name)));
|
|
80
|
+
const xs = $derived(ix.map((i) => +x[i]));
|
|
81
|
+
const isStack = $derived(area && !!stacked && !yLog);
|
|
82
|
+
const percent = $derived(isStack && stacked === 'percent');
|
|
83
|
+
const bandData = $derived(band && !isStack ? { lo: pick(band.lo), hi: pick(band.hi) } : null);
|
|
84
|
+
const bandAt = (i: number) => { const lo = bandData?.lo[i], hi = bandData?.hi[i]; return lo != null && hi != null ? { lo, hi } : null; };
|
|
85
|
+
const bandColor = $derived(band?.color ?? all[0]?.color ?? 'var(--tr-outline)');
|
|
86
|
+
const bandLabel = $derived(band?.label ?? 'Band');
|
|
87
|
+
const refs = $derived(referenceLines.filter((r) => !yLog || isPos(r.value)));
|
|
88
|
+
const xSpan = $derived(Math.max(...xs) - Math.min(...xs));
|
|
89
|
+
const fx = (v: number) => (xFormat ? xFormat(isDate ? new Date(v) : v) : fmtX(v, xSpan, isDate));
|
|
90
|
+
const refsText = $derived(refs.map((r) => `${r.label ?? 'Reference'} at ${yFormat(r.value)}`).join(', '));
|
|
91
|
+
const rangeText = (b: { lo: number; hi: number }) => `${yFormat(b.lo)} – ${yFormat(b.hi)}`;
|
|
92
|
+
|
|
93
|
+
let wrap = $state<HTMLElement>();
|
|
94
|
+
let fillH = $state(240);
|
|
95
|
+
const fill = $derived(height === 'fill');
|
|
96
|
+
const H = $derived(height === 'fill' ? fillH : height);
|
|
97
|
+
$effect(() => {
|
|
98
|
+
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();
|
|
109
|
+
});
|
|
44
110
|
/** Per visible series: [lo, hi] band at each x (stacked), else lo = 0, hi = value. */
|
|
45
111
|
const bands = $derived.by(() => {
|
|
46
112
|
if (!isStack) return vis.map((s) => s.values.map((v) => (v == null ? null : { lo: 0, hi: v })));
|
|
@@ -56,22 +122,32 @@
|
|
|
56
122
|
});
|
|
57
123
|
return out;
|
|
58
124
|
});
|
|
59
|
-
|
|
125
|
+
// only read in percent mode, which is always stacked: every band is then filled in
|
|
126
|
+
const share = (i: number, k: number) => { const b = bands[k][i]!; return b.hi - b.lo; };
|
|
127
|
+
// built in script: interpolating in markup would add unreachable `?? ''` branches
|
|
128
|
+
const areaPath = (seg: number[], X: (v: number) => number, Y: (v: number) => number, k: number) =>
|
|
129
|
+
`M${seg.map((i) => `${X(xs[i])},${Y(bands[k][i]!.hi)}`).join(' L')} L${seg.toReversed().map((i) => `${X(xs[i])},${Y(bands[k][i]!.lo)}`).join(' L')}Z`;
|
|
130
|
+
const tipName = (name: string, i: number, k: number) => `${name}${percent && vis[k].values[i] != null ? ` · ${fmtValue(share(i, k))}%` : ''}`;
|
|
131
|
+
const cellText = (s: (typeof all)[number], i: number) =>
|
|
132
|
+
`${s.values[i] == null ? '—' : str(yFormat(s.values[i]!))}${percent && s.values[i] != null && !hidden.includes(s.name) ? ` (${fmtValue(share(i, vis.indexOf(s)))}%)` : ''}`;
|
|
60
133
|
const labelled = $derived(directLabels && all.length > 1 && all.length <= 4);
|
|
61
134
|
const items = $derived(all.map((s) => ({ name: s.name, color: s.color, hidden: hidden.includes(s.name) })));
|
|
62
135
|
const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
|
|
63
136
|
|
|
64
|
-
const
|
|
137
|
+
const yDom = $derived.by(() => {
|
|
65
138
|
let lo = Infinity, hi = -Infinity;
|
|
66
|
-
|
|
139
|
+
const add = (v: number) => { if (!yLog || v > 0) { lo = Math.min(lo, v); hi = Math.max(hi, v); } };
|
|
140
|
+
for (const b of bands) for (const v of b) if (v != null) { add(v.lo); add(v.hi); }
|
|
141
|
+
for (let i = 0; i < xs.length; i++) { const b = bandAt(i); if (b) { add(b.lo); add(b.hi); } }
|
|
142
|
+
for (const r of refs) add(r.value);
|
|
67
143
|
if (percent) hi = 100;
|
|
68
|
-
if (!isFinite(lo)) { lo = 0; hi = 1; }
|
|
69
|
-
if (area || zeroBaseline) lo = Math.min(lo, 0);
|
|
70
|
-
return niceTicks(lo, hi, 5);
|
|
144
|
+
if (!isFinite(lo)) { lo = yLog ? 1 : 0; hi = yLog ? 10 : 1; }
|
|
145
|
+
if (!yLog && (area || zeroBaseline)) lo = Math.min(lo, 0);
|
|
146
|
+
return yLog ? logTicks(lo, hi) : niceTicks(lo, hi, 5);
|
|
71
147
|
});
|
|
72
148
|
|
|
73
|
-
function segments(values: (number | null)[]) {
|
|
74
|
-
if (isStack) return [values.map((_, i) => i)];
|
|
149
|
+
function segments(values: (number | null)[], forceGaps = false) {
|
|
150
|
+
if (isStack && !forceGaps) return [values.map((_, i) => i)];
|
|
75
151
|
const out: number[][] = [];
|
|
76
152
|
let cur: number[] = [];
|
|
77
153
|
values.forEach((v, i) => { if (v == null) { if (cur.length) out.push(cur); cur = []; } else cur.push(i); });
|
|
@@ -80,20 +156,21 @@
|
|
|
80
156
|
}
|
|
81
157
|
</script>
|
|
82
158
|
|
|
159
|
+
{#snippet chart()}
|
|
83
160
|
<ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
|
|
84
161
|
{#snippet plot(w)}
|
|
162
|
+
{@const height = H}
|
|
85
163
|
{@const m = { top: 12, right: labelled ? 92 : 14, bottom: 26, left: 46 }}
|
|
86
|
-
{@const X = linear(Math.min(...xs), Math.max(...xs), m.left, w - m.right)}
|
|
87
|
-
{@const Y = linear(
|
|
88
|
-
{@const
|
|
89
|
-
{@const xTicks = xs.length <= 8 ? xs : niceTicks(Math.min(...xs), Math.max(...xs), Math.max(2, Math.floor(w / 100))).ticks.filter((t) => t >= Math.min(...xs) && t <= Math.max(...xs))}
|
|
164
|
+
{@const X = (xLog ? logLinear : linear)(Math.min(...xs), Math.max(...xs), m.left, w - m.right)}
|
|
165
|
+
{@const Y = (yLog ? logLinear : linear)(yDom.min, yDom.max, height - m.bottom, m.top)}
|
|
166
|
+
{@const xTicks = xLog ? logTicks(Math.min(...xs), Math.max(...xs)).ticks.filter((t) => t >= Math.min(...xs) && t <= Math.max(...xs)) : xs.length <= 8 ? xs : niceTicks(Math.min(...xs), Math.max(...xs), Math.max(2, Math.floor(w / 100))).ticks.filter((t) => t >= Math.min(...xs) && t <= Math.max(...xs))}
|
|
90
167
|
{@const ends = vis.map((s, si) => { const k = s.values.findLastIndex((v) => v != null); return k < 0 ? null : { s, y: Y(bands[si][k]!.hi) }; }).filter((e) => !!e)}
|
|
91
168
|
{@const labelY = spreadLabels(ends.map((e) => e.y), 14, m.top + 4, height - m.bottom)}
|
|
92
169
|
|
|
93
170
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
94
171
|
<svg
|
|
95
172
|
width={w} {height} role="group" tabindex="0"
|
|
96
|
-
aria-label="{title ?? 'Line chart'}. Use left and right arrow keys to read values."
|
|
173
|
+
aria-label="{title ?? 'Line chart'}. Use left and right arrow keys to read values.{refsText && ` Reference lines: ${refsText}.`}"
|
|
97
174
|
onpointermove={(e) => {
|
|
98
175
|
const px = e.clientX - e.currentTarget.getBoundingClientRect().left;
|
|
99
176
|
let best = 0;
|
|
@@ -110,24 +187,35 @@
|
|
|
110
187
|
e.preventDefault();
|
|
111
188
|
}}
|
|
112
189
|
>
|
|
113
|
-
{#each
|
|
190
|
+
{#each yDom.ticks as t}
|
|
114
191
|
<line class="grid" x1={m.left} x2={w - m.right} y1={Y(t)} y2={Y(t)} />
|
|
115
192
|
<text class="tick" x={m.left - 8} y={Y(t)} text-anchor="end" dominant-baseline="middle">{fmtAxis(t)}</text>
|
|
116
193
|
{/each}
|
|
117
194
|
{#each xTicks as t}
|
|
118
|
-
<text class="tick" x={X(t)} y={height - 8} text-anchor="middle">{
|
|
195
|
+
<text class="tick" x={X(t)} y={height - 8} text-anchor="middle">{fx(t)}</text>
|
|
119
196
|
{/each}
|
|
120
197
|
|
|
198
|
+
{#if bandData}
|
|
199
|
+
{#each segments(bandData.lo.map((_, i) => (bandAt(i) ? 0 : null)), true) as seg}
|
|
200
|
+
<path class="band" fill={bandColor} d="M{seg.map((i) => `${X(xs[i])},${Y(bandAt(i)!.hi)}`).join(' L')} L{seg.toReversed().map((i) => `${X(xs[i])},${Y(bandAt(i)!.lo)}`).join(' L')}Z" />
|
|
201
|
+
{/each}
|
|
202
|
+
{/if}
|
|
203
|
+
|
|
121
204
|
{#each vis as s, si (s.name)}
|
|
122
205
|
{#if area}
|
|
123
206
|
{#each segments(s.values) as seg}
|
|
124
207
|
<path class="area" class:stack={isStack} fill={s.color}
|
|
125
|
-
d=
|
|
208
|
+
d={areaPath(seg, X, Y, si)} />
|
|
126
209
|
{/each}
|
|
127
210
|
{/if}
|
|
128
211
|
<path class="line" stroke={s.color} d={segments(s.values).map((seg) => seg.map((i, k) => `${k ? 'L' : 'M'}${X(xs[i]).toFixed(1)},${Y(bands[si][i]!.hi).toFixed(1)}`).join(' ')).join(' ')} />
|
|
129
212
|
{/each}
|
|
130
213
|
|
|
214
|
+
{#each refs as r}
|
|
215
|
+
<line class="ref" x1={m.left} x2={w - m.right} y1={Y(r.value)} y2={Y(r.value)} />
|
|
216
|
+
{#if r.label}<text class="reflabel" x={m.left + 4} y={Y(r.value) - 4}>{r.label}</text>{/if}
|
|
217
|
+
{/each}
|
|
218
|
+
|
|
131
219
|
{#each ends as e, k}
|
|
132
220
|
{#if labelled}<text class="direct" x={w - m.right + 8} y={labelY[k]} dominant-baseline="middle">{e.s.name}</text>{/if}
|
|
133
221
|
{/each}
|
|
@@ -142,25 +230,40 @@
|
|
|
142
230
|
|
|
143
231
|
{#if hover != null}
|
|
144
232
|
<Tooltip x={X(xs[hover])} y={height / 2} width={w}>
|
|
145
|
-
<div class="head">{
|
|
233
|
+
<div class="head">{fx(xs[hover])}</div>
|
|
146
234
|
{#each vis as s, si}
|
|
147
|
-
<div class="row"><span class="line-key" style:background={s.color}></span><b>{s.values[hover] == null ? '—' : yFormat(s.values[hover]!)}</b><span class="name">{s.name
|
|
235
|
+
<div class="row"><span class="line-key" style:background={s.color}></span><b>{s.values[hover] == null ? '—' : yFormat(s.values[hover]!)}</b><span class="name">{tipName(s.name, hover, si)}</span></div>
|
|
148
236
|
{/each}
|
|
237
|
+
{#if bandAt(hover)}
|
|
238
|
+
<div class="row"><span class="band-key" style:background={bandColor}></span><b>{rangeText(bandAt(hover)!)}</b><span class="name">{bandLabel}</span></div>
|
|
239
|
+
{/if}
|
|
149
240
|
</Tooltip>
|
|
150
241
|
{/if}
|
|
151
242
|
{/snippet}
|
|
152
243
|
|
|
153
244
|
{#snippet table()}
|
|
154
245
|
<table>
|
|
155
|
-
<thead><tr><th>{isDate ? 'Date' : 'X'}</th>{#each all as s}<th>{s.name}</th>{/each}</tr></thead>
|
|
246
|
+
<thead><tr><th>{isDate ? 'Date' : 'X'}</th>{#each all as s}<th>{s.name}</th>{/each}{#if bandData}<th>{bandLabel}</th>{/if}</tr></thead>
|
|
156
247
|
<tbody>
|
|
157
248
|
{#each xs as v, i}
|
|
158
|
-
<tr><td>{
|
|
249
|
+
<tr><td>{fx(v)}</td>{#each all as s}<td>{cellText(s, i)}</td>{/each}{#if bandData}<td>{bandAt(i) ? rangeText(bandAt(i)!) : '—'}</td>{/if}</tr>
|
|
159
250
|
{/each}
|
|
160
251
|
</tbody>
|
|
252
|
+
{#if refs.length}
|
|
253
|
+
<tfoot>
|
|
254
|
+
{#each refs as r}<tr><th scope="row">{r.label ?? 'Reference'}</th><td colspan={all.length + (bandData ? 1 : 0)}>{yFormat(r.value)}</td></tr>{/each}
|
|
255
|
+
</tfoot>
|
|
256
|
+
{/if}
|
|
161
257
|
</table>
|
|
162
258
|
{/snippet}
|
|
163
259
|
</ChartFrame>
|
|
260
|
+
{/snippet}
|
|
261
|
+
|
|
262
|
+
{#if fill}
|
|
263
|
+
<div class="fillwrap" bind:this={wrap}>{@render chart()}</div>
|
|
264
|
+
{:else}
|
|
265
|
+
{@render chart()}
|
|
266
|
+
{/if}
|
|
164
267
|
|
|
165
268
|
<style>
|
|
166
269
|
svg { display: block; outline: none; overflow: visible; }
|
|
@@ -170,6 +273,11 @@
|
|
|
170
273
|
.line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
|
171
274
|
.area { fill-opacity: 0.14; }
|
|
172
275
|
.area.stack { fill-opacity: 0.55; }
|
|
276
|
+
.band { fill-opacity: 0.18; stroke: none; }
|
|
277
|
+
.ref { stroke: var(--tr-on-surface); stroke-width: 1.5; stroke-dasharray: 6 3; }
|
|
278
|
+
.reflabel { fill: var(--tr-on-surface); font-size: var(--tr-text-xs); font-weight: 600; }
|
|
279
|
+
.band-key { width: 12px; height: 8px; border-radius: 2px; flex: none; opacity: 0.4; }
|
|
280
|
+
.fillwrap { height: 100%; min-height: 0; overflow: hidden; }
|
|
173
281
|
.direct { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-weight: 600; }
|
|
174
282
|
.cross { stroke: var(--tr-outline); stroke-width: 1; stroke-dasharray: 3 3; }
|
|
175
283
|
.dot { stroke: var(--tr-surface-container-low); stroke-width: 2; }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Series } from './types.js';
|
|
1
|
+
import type { Series, Band, ReferenceLine } from './types.js';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
/** Shared x positions: numbers or Dates. */
|
|
4
4
|
x: (number | Date)[];
|
|
@@ -6,14 +6,42 @@ type $$ComponentProps = {
|
|
|
6
6
|
series: Series[];
|
|
7
7
|
title?: string;
|
|
8
8
|
subtitle?: string;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Plot height in px (default 240), or `'fill'` to size to the container's height. `'fill'` measures the
|
|
11
|
+
* parent with a ResizeObserver, so the parent needs a definite height (e.g. `height: 360px`, or a grid/flex
|
|
12
|
+
* track with a fixed size). A parent with `height: auto` collapses to the 120px minimum.
|
|
13
|
+
*/
|
|
14
|
+
height?: number | 'fill';
|
|
10
15
|
area?: boolean;
|
|
11
16
|
/** Only meaningful with `area`. `true` stacks the series; `'percent'` normalises each x to 100%. Missing values count as 0 in a stack; tooltips and the table show the raw values. */
|
|
12
17
|
stacked?: boolean | 'percent';
|
|
18
|
+
/** Ensures 0 is inside the y-domain. It does not draw a line at 0: add `referenceLines={[{ value: 0 }]}` for that. */
|
|
13
19
|
zeroBaseline?: boolean;
|
|
14
20
|
/** Name labels at line ends when ≤ 4 series. */
|
|
15
21
|
directLabels?: boolean;
|
|
16
22
|
yFormat?: (n: number) => string;
|
|
23
|
+
/** Formats x values (a Date when `x` holds Dates) for axis ticks, tooltip and table. Default: number or date-aware short format. */
|
|
24
|
+
xFormat?: (x: number | Date) => string;
|
|
25
|
+
/**
|
|
26
|
+
* A chart-level shaded envelope drawn behind the lines (e.g. a confidence or min/max range). `lo` and `hi` align
|
|
27
|
+
* with `x` by index; `null` in either leaves a gap. It widens the y-domain, appears in the tooltip and table as
|
|
28
|
+
* `lo – hi` under `label` (default "Band"), and uses `color` (default: the first series' colour).
|
|
29
|
+
* Ignored when `stacked` is on.
|
|
30
|
+
*/
|
|
31
|
+
band?: Band;
|
|
32
|
+
/** Emphasised, labelled horizontal lines. Their values widen the y-domain; they are also announced in the chart's accessible name and listed under the table view. */
|
|
33
|
+
referenceLines?: ReferenceLine[];
|
|
34
|
+
/**
|
|
35
|
+
* `'log'` plots x on a base-10 log axis with ticks at powers of 10. Points with x <= 0 are dropped (not drawn,
|
|
36
|
+
* not in the tooltip or table). Ignored for Date x values.
|
|
37
|
+
*/
|
|
38
|
+
xScale?: 'linear' | 'log';
|
|
39
|
+
/**
|
|
40
|
+
* `'log'` plots y on a base-10 log axis with ticks at powers of 10. Values <= 0 (including band and
|
|
41
|
+
* reference values) are treated as missing and shown as an em dash; area fills run down to the axis
|
|
42
|
+
* minimum. `stacked` is ignored (series draw independently).
|
|
43
|
+
*/
|
|
44
|
+
yScale?: 'linear' | 'log';
|
|
17
45
|
};
|
|
18
46
|
declare const LineChart: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
19
47
|
type LineChart = ReturnType<typeof LineChart>;
|
package/dist/StatTile.svelte
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
delta,
|
|
10
10
|
deltaLabel = 'vs previous',
|
|
11
11
|
deltaGoodDirection = 'up',
|
|
12
|
-
spark
|
|
12
|
+
spark,
|
|
13
|
+
size = 'md'
|
|
13
14
|
}: {
|
|
14
15
|
label: string;
|
|
15
16
|
value: number | string;
|
|
@@ -22,12 +23,16 @@
|
|
|
22
23
|
deltaGoodDirection?: 'up' | 'down';
|
|
23
24
|
/** Recent values for a trailing trend line. */
|
|
24
25
|
spark?: number[];
|
|
26
|
+
/** `'sm'` uses `--tr-text-xl` for the value and tighter padding. The value size can also be set from outside with the `--trc-stat-value-size` custom property (default 28px). */
|
|
27
|
+
size?: 'sm' | 'md';
|
|
25
28
|
} = $props();
|
|
26
29
|
|
|
27
30
|
const shown = $derived(typeof value === 'number' ? (format ?? fmtValue)(value) : value);
|
|
28
31
|
const dir = $derived(delta == null || delta === 0 ? 'flat' : delta > 0 ? 'up' : 'down');
|
|
29
32
|
const tone = $derived(dir === 'flat' ? 'neutral' : dir === deltaGoodDirection ? 'good' : 'bad');
|
|
30
|
-
const
|
|
33
|
+
const signed = (d: number) => `${d > 0 ? '+' : d < 0 ? '−' : ''}${fmtValue(Math.abs(d))}%`;
|
|
34
|
+
// built in script: interpolating in markup would add an unreachable `?? ''` branch
|
|
35
|
+
const deltaClass = $derived(`delta ${tone}`);
|
|
31
36
|
const summary = $derived(
|
|
32
37
|
`${label}: ${shown}.` +
|
|
33
38
|
(delta == null ? '' : dir === 'flat' ? ` No change ${deltaLabel}.` : ` ${dir === 'up' ? 'Up' : 'Down'} ${fmtValue(Math.abs(delta))}% ${deltaLabel}, ${tone === 'good' ? 'favourable' : 'unfavourable'}.`) +
|
|
@@ -35,27 +40,29 @@
|
|
|
35
40
|
);
|
|
36
41
|
</script>
|
|
37
42
|
|
|
38
|
-
<div class="tile" role="group" aria-label={summary}>
|
|
43
|
+
<div class="tile" class:sm={size === 'sm'} role="group" aria-label={summary}>
|
|
39
44
|
<div class="label" aria-hidden="true">{label}</div>
|
|
40
45
|
<div class="row" aria-hidden="true">
|
|
41
46
|
<div class="value">{shown}</div>
|
|
42
47
|
{#if spark && spark.length > 1}<Sparkline values={spark} {label} />{/if}
|
|
43
48
|
</div>
|
|
44
49
|
{#if delta != null}
|
|
45
|
-
<div class=
|
|
50
|
+
<div class={deltaClass} data-dir={dir} aria-hidden="true">
|
|
46
51
|
<svg viewBox="0 0 12 12" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
47
52
|
{#if dir === 'up'}<path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" />{:else if dir === 'down'}<path d="M6 2v8M2.5 6.5 6 10l3.5-3.5" />{:else}<path d="M2 6h8" />{/if}
|
|
48
53
|
</svg>
|
|
49
|
-
<b>{
|
|
54
|
+
<b>{signed(delta)}</b><span class="vs">{deltaLabel}</span>
|
|
50
55
|
</div>
|
|
51
56
|
{/if}
|
|
52
57
|
</div>
|
|
53
58
|
|
|
54
59
|
<style>
|
|
55
60
|
.tile { display: flex; flex-direction: column; gap: 4px; padding: 12px 16px; background: var(--tr-surface-container-low); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); color: var(--tr-on-surface); font-family: var(--tr-font-sans); }
|
|
61
|
+
.tile.sm { padding: 8px 12px; gap: 2px; }
|
|
62
|
+
.tile.sm .value { font-size: var(--trc-stat-value-size, var(--tr-text-xl)); }
|
|
56
63
|
.label { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-weight: 600; }
|
|
57
64
|
.row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; }
|
|
58
|
-
.value { font-size: 28px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
|
65
|
+
.value { font-size: var(--trc-stat-value-size, 28px); font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
|
59
66
|
.delta { display: inline-flex; align-items: center; gap: 4px; font-size: var(--tr-text-sm); font-variant-numeric: tabular-nums; }
|
|
60
67
|
.delta.good { color: var(--tr-success); }
|
|
61
68
|
.delta.bad { color: var(--tr-danger); }
|
|
@@ -10,6 +10,8 @@ type $$ComponentProps = {
|
|
|
10
10
|
deltaGoodDirection?: 'up' | 'down';
|
|
11
11
|
/** Recent values for a trailing trend line. */
|
|
12
12
|
spark?: number[];
|
|
13
|
+
/** `'sm'` uses `--tr-text-xl` for the value and tighter padding. The value size can also be set from outside with the `--trc-stat-value-size` custom property (default 28px). */
|
|
14
|
+
size?: 'sm' | 'md';
|
|
13
15
|
};
|
|
14
16
|
declare const StatTile: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
15
17
|
type StatTile = ReturnType<typeof StatTile>;
|
package/dist/charts.css
CHANGED
|
@@ -17,3 +17,31 @@
|
|
|
17
17
|
--trc-s1: #3987e5; --trc-s2: #d95926; --trc-s3: #199e70; --trc-s4: #c98500;
|
|
18
18
|
--trc-s5: #d55181; --trc-s6: #008300; --trc-s7: #9085e9; --trc-s8: #e66767;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Sequential (--trc-seq-1..7) and diverging (--trc-div-1..7) ramps for continuous scales (heatmaps, choropleths, ColorScale).
|
|
23
|
+
* Sequential: one blue hue, lightness strictly monotonic from low (seq-1) to high (seq-7). In light mode low is the lightest
|
|
24
|
+
* step, in dark mode low is the darkest, so "more" always reads as stronger contrast against the surface.
|
|
25
|
+
* Diverging: blue (div-1) through a neutral midpoint (div-4) to orange (div-7). Each half is monotonic in lightness toward the
|
|
26
|
+
* neutral, and the poles stay distinguishable under protan, deutan and tritan simulation (OKLab delta-E >= 0.19).
|
|
27
|
+
*/
|
|
28
|
+
:root {
|
|
29
|
+
--trc-seq-1: #eaf3fe; --trc-seq-2: #c5ddfc; --trc-seq-3: #97c1f7; --trc-seq-4: #64a1ee;
|
|
30
|
+
--trc-seq-5: #3a81d7; --trc-seq-6: #1d60ad; --trc-seq-7: #10427b;
|
|
31
|
+
--trc-div-1: #11458c; --trc-div-2: #3c79d1; --trc-div-3: #94b9f1; --trc-div-4: #f0eeeb;
|
|
32
|
+
--trc-div-5: #ebaf86; --trc-div-6: #d26c28; --trc-div-7: #993e02;
|
|
33
|
+
}
|
|
34
|
+
@media (prefers-color-scheme: dark) {
|
|
35
|
+
:root:not([data-theme='light']) {
|
|
36
|
+
--trc-seq-1: #0e294a; --trc-seq-2: #113d70; --trc-seq-3: #1b589e; --trc-seq-4: #3077cd;
|
|
37
|
+
--trc-seq-5: #589aed; --trc-seq-6: #8fbefa; --trc-seq-7: #c5ddfc;
|
|
38
|
+
--trc-div-1: #86bafe; --trc-div-2: #4c8edf; --trc-div-3: #285994; --trc-div-4: #2a2926;
|
|
39
|
+
--trc-div-5: #944e21; --trc-div-6: #dc7b40; --trc-div-7: #f9a870;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
:root[data-theme='dark'] {
|
|
43
|
+
--trc-seq-1: #0e294a; --trc-seq-2: #113d70; --trc-seq-3: #1b589e; --trc-seq-4: #3077cd;
|
|
44
|
+
--trc-seq-5: #589aed; --trc-seq-6: #8fbefa; --trc-seq-7: #c5ddfc;
|
|
45
|
+
--trc-div-1: #86bafe; --trc-div-2: #4c8edf; --trc-div-3: #285994; --trc-div-4: #2a2926;
|
|
46
|
+
--trc-div-5: #944e21; --trc-div-6: #dc7b40; --trc-div-7: #f9a870;
|
|
47
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type DensityPoint = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
group?: string;
|
|
5
|
+
};
|
|
6
|
+
export type View = {
|
|
7
|
+
x0: number;
|
|
8
|
+
x1: number;
|
|
9
|
+
y0: number;
|
|
10
|
+
y1: number;
|
|
11
|
+
};
|
|
12
|
+
export type Prepared = {
|
|
13
|
+
xs: ArrayLike<number>;
|
|
14
|
+
ys: ArrayLike<number>;
|
|
15
|
+
/** Palette slot per point, or `null` when nothing is grouped (fast path). */
|
|
16
|
+
g: Uint8Array | null;
|
|
17
|
+
names: string[];
|
|
18
|
+
n: number;
|
|
19
|
+
/** Finite data extent, or `null` when there is nothing finite to plot. */
|
|
20
|
+
extent: View | null;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Normalises either input style into parallel arrays. Typed arrays are used as-is (no copy).
|
|
24
|
+
* Groups beyond the 8 palette slots, and points without a group in a grouped set, take slot 0.
|
|
25
|
+
*/
|
|
26
|
+
export declare function prepare(points: DensityPoint[] | undefined, xs: ArrayLike<number> | undefined, ys: ArrayLike<number> | undefined, groupIds?: Uint8Array, groupNames?: string[]): Prepared;
|
|
27
|
+
/**
|
|
28
|
+
* Counts the points inside `v` into `out` (cols x rows, row 0 at the bottom, y up) in one pass, skipping hidden
|
|
29
|
+
* groups and non-finite values. Returns how many points fell inside the view. No allocation.
|
|
30
|
+
*/
|
|
31
|
+
export declare function bin(p: Prepared, hid: Uint8Array, v: View, cols: number, rows: number, out: Uint32Array): number;
|
|
32
|
+
/** Index of the visible point nearest to the domain position (qx, qy) within `rx`/`ry` domain units, or -1. */
|
|
33
|
+
export declare function nearest(p: Prepared, hid: Uint8Array, qx: number, qy: number, rx: number, ry: number): number;
|
|
34
|
+
export declare const isHome: (v: View, h: View) => boolean;
|
|
35
|
+
/** Zooms by factor `k` (>1 zooms in) keeping the point at fractions (fx, fy from the top) of the plot fixed. */
|
|
36
|
+
export declare function zoomView(v: View, h: View, fx: number, fy: number, k: number): View;
|
|
37
|
+
/** Shifts the window by fractions of its own span (positive fx moves right, positive fy moves up). */
|
|
38
|
+
export declare function panView(v: View, h: View, fx: number, fy: number): View;
|
|
39
|
+
/** Fallback sequential ramp (colour-blind safe, readable on light and dark surfaces); overridden by `--trc-seq-1..9`. */
|
|
40
|
+
export declare const DEFAULT_RAMP: string[];
|
|
41
|
+
/** `#rrggbb` or `rgb[a](r, g, b…)` (the forms a canvas returns for fillStyle) to [r, g, b]. */
|
|
42
|
+
export declare function parseColor(s: string): [number, number, number];
|
|
43
|
+
/** Interpolates the ramp at t in [0, 1]. */
|
|
44
|
+
export declare function rampColor(stops: [number, number, number][], t: number): string;
|