@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.
@@ -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="{a.name}: {format(a.value)} ({pct(a.value)}%)"
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)}%</td></tr>{/each}
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}
@@ -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">{xFormat(edges[hover])} – {xFormat(edges[hover + 1])}</div>
131
- <div class="row"><b>{counts[hover]}</b><span class="name">{total ? Math.round((counts[hover] / total) * 100) : 0}% of {total}</span></div>
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>{xFormat(edges[i])} – {xFormat(edges[i + 1])}</td><td>{c}</td><td>{total ? Math.round((c / total) * 100) : 0}%</td></tr>
144
+ <tr><td>{span(i)}</td><td>{c}</td><td>{`${share(c)}%`}</td></tr>
142
145
  {/each}
143
146
  </tbody>
144
147
  </table>
@@ -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
- height?: number;
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 xs = $derived(x.map((v) => +v));
42
- const isStack = $derived(area && !!stacked);
43
- const percent = $derived(area && stacked === 'percent');
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
- const share = (i: number, k: number) => { const b = bands[k][i]; return b ? b.hi - b.lo : 0; };
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 yScale = $derived.by(() => {
137
+ const yDom = $derived.by(() => {
65
138
  let lo = Infinity, hi = -Infinity;
66
- for (const b of bands) for (const v of b) if (v != null) { lo = Math.min(lo, v.lo, v.hi); hi = Math.max(hi, v.lo, v.hi); }
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(yScale.min, yScale.max, height - m.bottom, m.top)}
88
- {@const span = Math.max(...xs) - Math.min(...xs)}
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 yScale.ticks as t}
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">{fmtX(t, span, isDate)}</text>
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="M{seg.map((i) => `${X(xs[i])},${Y(bands[si][i]!.hi)}`).join(' L')} L{seg.toReversed().map((i) => `${X(xs[i])},${Y(bands[si][i]!.lo)}`).join(' L')}Z" />
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">{fmtX(xs[hover], span, isDate)}</div>
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}{#if percent && s.values[hover] != null} · {fmtValue(share(hover, si))}%{/if}</span></div>
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>{fmtX(v, Math.max(...xs) - Math.min(...xs), isDate)}</td>{#each all as s}<td>{s.values[i] == null ? '—' : yFormat(s.values[i]!)}{#if percent && s.values[i] != null && !hidden.includes(s.name)} ({fmtValue(share(i, vis.indexOf(s)))}%){/if}</td>{/each}</tr>
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
- height?: number;
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>;
@@ -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 deltaText = $derived(delta == null ? '' : `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${fmtValue(Math.abs(delta))}%`);
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="delta {tone}" data-dir={dir} aria-hidden="true">
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>{deltaText}</b><span class="vs">{deltaLabel}</span>
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;