@ruckc/truss-chart 1.2.0 → 1.3.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.
@@ -11,6 +11,7 @@
11
11
  subtitle,
12
12
  height = 240,
13
13
  area = false,
14
+ stacked = false,
14
15
  zeroBaseline = false,
15
16
  directLabels = true,
16
17
  yFormat = fmtValue
@@ -23,6 +24,8 @@
23
24
  subtitle?: string;
24
25
  height?: number;
25
26
  area?: boolean;
27
+ /** 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
+ stacked?: boolean | 'percent';
26
29
  zeroBaseline?: boolean;
27
30
  /** Name labels at line ends when ≤ 4 series. */
28
31
  directLabels?: boolean;
@@ -36,19 +39,39 @@
36
39
  const vis = $derived(all.filter((s) => !hidden.includes(s.name)));
37
40
  const isDate = $derived(x[0] instanceof Date);
38
41
  const xs = $derived(x.map((v) => +v));
42
+ const isStack = $derived(area && !!stacked);
43
+ const percent = $derived(area && stacked === 'percent');
44
+ /** Per visible series: [lo, hi] band at each x (stacked), else lo = 0, hi = value. */
45
+ const bands = $derived.by(() => {
46
+ if (!isStack) return vis.map((s) => s.values.map((v) => (v == null ? null : { lo: 0, hi: v })));
47
+ const out = vis.map((s) => s.values.map(() => ({ lo: 0, hi: 0 })));
48
+ xs.forEach((_, i) => {
49
+ const total = percent ? vis.reduce((a, s) => a + Math.abs(s.values[i] ?? 0), 0) || 1 : 1;
50
+ let acc = 0;
51
+ vis.forEach((s, k) => {
52
+ const v = percent ? (Math.abs(s.values[i] ?? 0) / total) * 100 : (s.values[i] ?? 0);
53
+ out[k][i] = { lo: acc, hi: acc + v };
54
+ acc += v;
55
+ });
56
+ });
57
+ return out;
58
+ });
59
+ const share = (i: number, k: number) => { const b = bands[k][i]; return b ? b.hi - b.lo : 0; };
39
60
  const labelled = $derived(directLabels && all.length > 1 && all.length <= 4);
40
61
  const items = $derived(all.map((s) => ({ name: s.name, color: s.color, hidden: hidden.includes(s.name) })));
41
62
  const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
42
63
 
43
64
  const yScale = $derived.by(() => {
44
65
  let lo = Infinity, hi = -Infinity;
45
- for (const s of vis) for (const v of s.values) if (v != null) { lo = Math.min(lo, v); hi = Math.max(hi, v); }
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); }
67
+ if (percent) hi = 100;
46
68
  if (!isFinite(lo)) { lo = 0; hi = 1; }
47
69
  if (area || zeroBaseline) lo = Math.min(lo, 0);
48
70
  return niceTicks(lo, hi, 5);
49
71
  });
50
72
 
51
73
  function segments(values: (number | null)[]) {
74
+ if (isStack) return [values.map((_, i) => i)];
52
75
  const out: number[][] = [];
53
76
  let cur: number[] = [];
54
77
  values.forEach((v, i) => { if (v == null) { if (cur.length) out.push(cur); cur = []; } else cur.push(i); });
@@ -64,7 +87,7 @@
64
87
  {@const Y = linear(yScale.min, yScale.max, height - m.bottom, m.top)}
65
88
  {@const span = Math.max(...xs) - Math.min(...xs)}
66
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))}
67
- {@const ends = vis.map((s) => { const k = s.values.findLastIndex((v) => v != null); return k < 0 ? null : { s, y: Y(s.values[k]!) }; }).filter((e) => !!e)}
90
+ {@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)}
68
91
  {@const labelY = spreadLabels(ends.map((e) => e.y), 14, m.top + 4, height - m.bottom)}
69
92
 
70
93
  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
@@ -95,14 +118,14 @@
95
118
  <text class="tick" x={X(t)} y={height - 8} text-anchor="middle">{fmtX(t, span, isDate)}</text>
96
119
  {/each}
97
120
 
98
- {#each vis as s (s.name)}
121
+ {#each vis as s, si (s.name)}
99
122
  {#if area}
100
123
  {#each segments(s.values) as seg}
101
- <path class="area" fill={s.color}
102
- d="M{X(xs[seg[0]])},{Y(Math.max(yScale.min, 0))} {seg.map((i) => `L${X(xs[i])},${Y(s.values[i]!)}`).join(' ')} L{X(xs[seg.at(-1)!])},{Y(Math.max(yScale.min, 0))}Z" />
124
+ <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" />
103
126
  {/each}
104
127
  {/if}
105
- <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(s.values[i]!).toFixed(1)}`).join(' ')).join(' ')} />
128
+ <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(' ')} />
106
129
  {/each}
107
130
 
108
131
  {#each ends as e, k}
@@ -111,8 +134,8 @@
111
134
 
112
135
  {#if hover != null}
113
136
  <line class="cross" x1={X(xs[hover])} x2={X(xs[hover])} y1={m.top} y2={height - m.bottom} />
114
- {#each vis as s}
115
- {#if s.values[hover] != null}<circle class="dot" cx={X(xs[hover])} cy={Y(s.values[hover]!)} r="4" fill={s.color} />{/if}
137
+ {#each vis as s, si}
138
+ {#if bands[si][hover] != null}<circle class="dot" cx={X(xs[hover])} cy={Y(bands[si][hover]!.hi)} r="4" fill={s.color} />{/if}
116
139
  {/each}
117
140
  {/if}
118
141
  </svg>
@@ -120,8 +143,8 @@
120
143
  {#if hover != null}
121
144
  <Tooltip x={X(xs[hover])} y={height / 2} width={w}>
122
145
  <div class="head">{fmtX(xs[hover], span, isDate)}</div>
123
- {#each vis as s}
124
- <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}</span></div>
146
+ {#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>
125
148
  {/each}
126
149
  </Tooltip>
127
150
  {/if}
@@ -132,7 +155,7 @@
132
155
  <thead><tr><th>{isDate ? 'Date' : 'X'}</th>{#each all as s}<th>{s.name}</th>{/each}</tr></thead>
133
156
  <tbody>
134
157
  {#each xs as v, i}
135
- <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]!)}</td>{/each}</tr>
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>
136
159
  {/each}
137
160
  </tbody>
138
161
  </table>
@@ -146,6 +169,7 @@
146
169
  .tick { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); font-variant-numeric: tabular-nums; }
147
170
  .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
148
171
  .area { fill-opacity: 0.14; }
172
+ .area.stack { fill-opacity: 0.55; }
149
173
  .direct { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-weight: 600; }
150
174
  .cross { stroke: var(--tr-outline); stroke-width: 1; stroke-dasharray: 3 3; }
151
175
  .dot { stroke: var(--tr-surface-container-low); stroke-width: 2; }
@@ -8,6 +8,8 @@ type $$ComponentProps = {
8
8
  subtitle?: string;
9
9
  height?: number;
10
10
  area?: boolean;
11
+ /** 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
+ stacked?: boolean | 'percent';
11
13
  zeroBaseline?: boolean;
12
14
  /** Name labels at line ends when ≤ 4 series. */
13
15
  directLabels?: boolean;
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import ChartFrame from './ChartFrame.svelte';
3
+ import Tooltip from './Tooltip.svelte';
4
+ import type { ScatterPoint } from './types.js';
5
+ import { fmtAxis, fmtValue, linear, niceTicks, seriesColor, MAX_SERIES } from './util.js';
6
+
7
+ let {
8
+ points,
9
+ title,
10
+ subtitle,
11
+ height = 280,
12
+ xLabel,
13
+ yLabel,
14
+ sizeLabel = 'Size',
15
+ sizeRange = [8, 24],
16
+ xFormat = fmtValue,
17
+ yFormat = fmtValue,
18
+ sizeFormat = fmtValue
19
+ }: {
20
+ /** `size` encodes a third value as marker diameter; `group` picks the (fixed-order, max 8) palette colour and legend entry. */
21
+ points: ScatterPoint[];
22
+ title?: string;
23
+ subtitle?: string;
24
+ height?: number;
25
+ xLabel?: string;
26
+ yLabel?: string;
27
+ /** Name of the size dimension in tooltips and the table. */
28
+ sizeLabel?: string;
29
+ /** Marker diameter in px for the smallest and largest `size`. Never drawn below 8px. */
30
+ sizeRange?: [number, number];
31
+ xFormat?: (n: number) => string;
32
+ yFormat?: (n: number) => string;
33
+ sizeFormat?: (n: number) => string;
34
+ } = $props();
35
+
36
+ let hidden = $state<string[]>([]);
37
+ let active = $state<number | null>(null);
38
+
39
+ const groups = $derived([...new Set(points.map((p) => p.group).filter((g): g is string => g != null))].slice(0, MAX_SERIES));
40
+ const colorOf = (g?: string) => seriesColor(Math.max(0, g == null ? 0 : groups.indexOf(g)));
41
+ const items = $derived(groups.map((g) => ({ name: g, color: colorOf(g), hidden: hidden.includes(g) })));
42
+ const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
43
+ const hasSize = $derived(points.some((p) => p.size != null));
44
+
45
+ /** Visible points in reading order (x, then y); keyboard navigation walks this list. */
46
+ const vis = $derived(
47
+ points
48
+ .map((p, idx) => ({ ...p, idx }))
49
+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && !(p.group != null && hidden.includes(p.group)))
50
+ .sort((a, b) => a.x - b.x || a.y - b.y)
51
+ );
52
+ const sizes = $derived(vis.map((p) => p.size).filter((v): v is number => v != null && Number.isFinite(v)));
53
+ const sMin = $derived(Math.min(...sizes));
54
+ const sMax = $derived(Math.max(...sizes));
55
+ const diameter = (s?: number) => {
56
+ const lo = Math.max(8, sizeRange[0]), hi = Math.max(lo, sizeRange[1]);
57
+ if (s == null || !sizes.length || !Number.isFinite(s)) return lo;
58
+ return lo + (sMax === sMin ? 0.5 : (s - sMin) / (sMax - sMin)) * (hi - lo);
59
+ };
60
+
61
+ const xScale = $derived(vis.length ? niceTicks(Math.min(...vis.map((p) => p.x)), Math.max(...vis.map((p) => p.x)), 6) : niceTicks(0, 1, 5));
62
+ const yScale = $derived(vis.length ? niceTicks(Math.min(...vis.map((p) => p.y)), Math.max(...vis.map((p) => p.y)), 5) : niceTicks(0, 1, 5));
63
+ const cur = $derived(active != null ? (vis[active] ?? null) : null);
64
+ const readout = (p: (typeof vis)[number]) =>
65
+ `${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)}` : ''}`;
66
+ </script>
67
+
68
+ <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
69
+ {#snippet plot(w)}
70
+ {@const m = { top: 14, right: 16, bottom: xLabel ? 44 : 28, left: yLabel ? 62 : 48 }}
71
+ {@const X = linear(xScale.min, xScale.max, m.left, w - m.right)}
72
+ {@const Y = linear(yScale.min, yScale.max, height - m.bottom, m.top)}
73
+
74
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
75
+ <svg
76
+ width={w} {height} role="group" tabindex="0"
77
+ aria-label="{title ?? 'Scatter chart'}, {vis.length} points. Use arrow keys to move between points."
78
+ onpointermove={(e) => {
79
+ const r = e.currentTarget.getBoundingClientRect();
80
+ const px = e.clientX - r.left, py = e.clientY - r.top;
81
+ let best = -1, bd = 28 * 28;
82
+ vis.forEach((p, i) => { const d = (X(p.x) - px) ** 2 + (Y(p.y) - py) ** 2; if (d < bd) { bd = d; best = i; } });
83
+ active = best < 0 ? null : best;
84
+ }}
85
+ onpointerleave={() => (active = null)}
86
+ onblur={() => (active = null)}
87
+ onkeydown={(e) => {
88
+ const n = vis.length;
89
+ if (!n) return;
90
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') active = Math.min(n - 1, (active ?? -1) + 1);
91
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') active = Math.max(0, (active ?? n) - 1);
92
+ else if (e.key === 'Home') active = 0;
93
+ else if (e.key === 'End') active = n - 1;
94
+ else if (e.key === 'Escape') active = null;
95
+ else return;
96
+ e.preventDefault();
97
+ }}
98
+ >
99
+ {#each yScale.ticks as t}
100
+ <line class="grid" x1={m.left} x2={w - m.right} y1={Y(t)} y2={Y(t)} />
101
+ <text class="tick" x={m.left - 8} y={Y(t)} text-anchor="end" dominant-baseline="middle">{fmtAxis(t)}</text>
102
+ {/each}
103
+ {#each xScale.ticks as t}
104
+ <text class="tick" x={X(t)} y={height - m.bottom + 16} text-anchor="middle">{fmtAxis(t)}</text>
105
+ {/each}
106
+ {#if xLabel}<text class="axis" x={(m.left + w - m.right) / 2} y={height - 6} text-anchor="middle">{xLabel}</text>{/if}
107
+ {#if yLabel}<text class="axis" transform="translate(12 {(m.top + height - m.bottom) / 2}) rotate(-90)" text-anchor="middle">{yLabel}</text>{/if}
108
+
109
+ {#each vis as p, i (p.idx)}
110
+ <circle class="pt" class:on={i === active} cx={X(p.x)} cy={Y(p.y)} r={diameter(p.size) / 2} fill={colorOf(p.group)} />
111
+ {/each}
112
+ {#if cur}
113
+ <circle class="focus" cx={X(cur.x)} cy={Y(cur.y)} r={diameter(cur.size) / 2 + 4} />
114
+ {/if}
115
+ </svg>
116
+ <div class="sr" role="status" aria-live="polite">{cur ? readout(cur) : ''}</div>
117
+
118
+ {#if cur}
119
+ <Tooltip x={X(cur.x)} y={Y(cur.y)} width={w}>
120
+ <div class="head">{cur.label ?? 'Point'}{#if cur.group} · {cur.group}{/if}</div>
121
+ <div class="row"><b>{xFormat(cur.x)}</b><span class="name">{xLabel ?? 'x'}</span></div>
122
+ <div class="row"><b>{yFormat(cur.y)}</b><span class="name">{yLabel ?? 'y'}</span></div>
123
+ {#if cur.size != null}<div class="row"><b>{sizeFormat(cur.size)}</b><span class="name">{sizeLabel}</span></div>{/if}
124
+ </Tooltip>
125
+ {/if}
126
+ {/snippet}
127
+
128
+ {#snippet table()}
129
+ <table>
130
+ <thead><tr><th>Point</th>{#if groups.length}<th>Group</th>{/if}<th>{xLabel ?? 'X'}</th><th>{yLabel ?? 'Y'}</th>{#if hasSize}<th>{sizeLabel}</th>{/if}</tr></thead>
131
+ <tbody>
132
+ {#each points as p}
133
+ <tr><td>{p.label ?? '—'}</td>{#if groups.length}<td>{p.group ?? '—'}</td>{/if}<td>{xFormat(p.x)}</td><td>{yFormat(p.y)}</td>{#if hasSize}<td>{p.size == null ? '—' : sizeFormat(p.size)}</td>{/if}</tr>
134
+ {/each}
135
+ </tbody>
136
+ </table>
137
+ {/snippet}
138
+ </ChartFrame>
139
+
140
+ <style>
141
+ svg { display: block; outline: none; overflow: visible; }
142
+ svg:focus-visible { border-radius: var(--tr-radius-sm); box-shadow: var(--tr-focus-ring); }
143
+ .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; stroke-width: 1; }
144
+ .tick { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); font-variant-numeric: tabular-nums; }
145
+ .axis { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-weight: 600; }
146
+ .pt { fill-opacity: 0.85; stroke: var(--tr-surface-container-low); stroke-width: 2; }
147
+ .pt.on { fill-opacity: 1; }
148
+ .focus { fill: none; stroke: var(--tr-on-surface); stroke-width: 2; pointer-events: none; }
149
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
150
+ .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
151
+ .row { display: flex; align-items: center; gap: 8px; line-height: 1.5; }
152
+ .row b { color: var(--tr-on-surface); font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; }
153
+ .name { color: var(--tr-on-surface-variant); }
154
+ </style>
@@ -0,0 +1,20 @@
1
+ import type { ScatterPoint } from './types.js';
2
+ type $$ComponentProps = {
3
+ /** `size` encodes a third value as marker diameter; `group` picks the (fixed-order, max 8) palette colour and legend entry. */
4
+ points: ScatterPoint[];
5
+ title?: string;
6
+ subtitle?: string;
7
+ height?: number;
8
+ xLabel?: string;
9
+ yLabel?: string;
10
+ /** Name of the size dimension in tooltips and the table. */
11
+ sizeLabel?: string;
12
+ /** Marker diameter in px for the smallest and largest `size`. Never drawn below 8px. */
13
+ sizeRange?: [number, number];
14
+ xFormat?: (n: number) => string;
15
+ yFormat?: (n: number) => string;
16
+ sizeFormat?: (n: number) => string;
17
+ };
18
+ declare const ScatterChart: import("svelte").Component<$$ComponentProps, {}, "">;
19
+ type ScatterChart = ReturnType<typeof ScatterChart>;
20
+ export default ScatterChart;
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ import Sparkline from './Sparkline.svelte';
3
+ import { fmtValue } from './util.js';
4
+
5
+ let {
6
+ label,
7
+ value,
8
+ format,
9
+ delta,
10
+ deltaLabel = 'vs previous',
11
+ deltaGoodDirection = 'up',
12
+ spark
13
+ }: {
14
+ label: string;
15
+ value: number | string;
16
+ /** Formats a numeric `value` (default: grouped, max 2 decimals). */
17
+ format?: (n: number) => string;
18
+ /** Change vs the previous period, in percent (4.2 means +4.2%). */
19
+ delta?: number;
20
+ deltaLabel?: string;
21
+ /** Which direction of change is good: picks the success or danger colour. The arrow and signed text carry the meaning without colour. */
22
+ deltaGoodDirection?: 'up' | 'down';
23
+ /** Recent values for a trailing trend line. */
24
+ spark?: number[];
25
+ } = $props();
26
+
27
+ const shown = $derived(typeof value === 'number' ? (format ?? fmtValue)(value) : value);
28
+ const dir = $derived(delta == null || delta === 0 ? 'flat' : delta > 0 ? 'up' : 'down');
29
+ const tone = $derived(dir === 'flat' ? 'neutral' : dir === deltaGoodDirection ? 'good' : 'bad');
30
+ const deltaText = $derived(delta == null ? '' : `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${fmtValue(Math.abs(delta))}%`);
31
+ const summary = $derived(
32
+ `${label}: ${shown}.` +
33
+ (delta == null ? '' : dir === 'flat' ? ` No change ${deltaLabel}.` : ` ${dir === 'up' ? 'Up' : 'Down'} ${fmtValue(Math.abs(delta))}% ${deltaLabel}, ${tone === 'good' ? 'favourable' : 'unfavourable'}.`) +
34
+ (spark && spark.length > 1 ? ` Trend over ${spark.length} points, from ${fmtValue(spark[0])} to ${fmtValue(spark.at(-1)!)}.` : '')
35
+ );
36
+ </script>
37
+
38
+ <div class="tile" role="group" aria-label={summary}>
39
+ <div class="label" aria-hidden="true">{label}</div>
40
+ <div class="row" aria-hidden="true">
41
+ <div class="value">{shown}</div>
42
+ {#if spark && spark.length > 1}<Sparkline values={spark} {label} />{/if}
43
+ </div>
44
+ {#if delta != null}
45
+ <div class="delta {tone}" data-dir={dir} aria-hidden="true">
46
+ <svg viewBox="0 0 12 12" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
47
+ {#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
+ </svg>
49
+ <b>{deltaText}</b><span class="vs">{deltaLabel}</span>
50
+ </div>
51
+ {/if}
52
+ </div>
53
+
54
+ <style>
55
+ .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); }
56
+ .label { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-weight: 600; }
57
+ .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; }
59
+ .delta { display: inline-flex; align-items: center; gap: 4px; font-size: var(--tr-text-sm); font-variant-numeric: tabular-nums; }
60
+ .delta.good { color: var(--tr-success); }
61
+ .delta.bad { color: var(--tr-danger); }
62
+ .delta.neutral { color: var(--tr-on-surface-variant); }
63
+ .vs { color: var(--tr-on-surface-variant); }
64
+ </style>
@@ -0,0 +1,16 @@
1
+ type $$ComponentProps = {
2
+ label: string;
3
+ value: number | string;
4
+ /** Formats a numeric `value` (default: grouped, max 2 decimals). */
5
+ format?: (n: number) => string;
6
+ /** Change vs the previous period, in percent (4.2 means +4.2%). */
7
+ delta?: number;
8
+ deltaLabel?: string;
9
+ /** Which direction of change is good: picks the success or danger colour. The arrow and signed text carry the meaning without colour. */
10
+ deltaGoodDirection?: 'up' | 'down';
11
+ /** Recent values for a trailing trend line. */
12
+ spark?: number[];
13
+ };
14
+ declare const StatTile: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type StatTile = ReturnType<typeof StatTile>;
16
+ export default StatTile;
package/dist/index.d.ts CHANGED
@@ -3,5 +3,7 @@ export { default as BarChart } from './BarChart.svelte';
3
3
  export { default as DonutChart } from './DonutChart.svelte';
4
4
  export { default as Histogram } from './Histogram.svelte';
5
5
  export { default as Sparkline } from './Sparkline.svelte';
6
+ export { default as ScatterChart } from './ScatterChart.svelte';
7
+ export { default as StatTile } from './StatTile.svelte';
6
8
  export { seriesColor, niceTicks, linear } from './util.js';
7
- export type { Series } from './types.js';
9
+ export type { Series, ScatterPoint } from './types.js';
package/dist/index.js CHANGED
@@ -3,4 +3,6 @@ export { default as BarChart } from './BarChart.svelte';
3
3
  export { default as DonutChart } from './DonutChart.svelte';
4
4
  export { default as Histogram } from './Histogram.svelte';
5
5
  export { default as Sparkline } from './Sparkline.svelte';
6
+ export { default as ScatterChart } from './ScatterChart.svelte';
7
+ export { default as StatTile } from './StatTile.svelte';
6
8
  export { seriesColor, niceTicks, linear } from './util.js';
package/dist/types.d.ts CHANGED
@@ -7,3 +7,10 @@ export type LegendItem = {
7
7
  color: string;
8
8
  hidden?: boolean;
9
9
  };
10
+ export type ScatterPoint = {
11
+ x: number;
12
+ y: number;
13
+ size?: number;
14
+ group?: string;
15
+ label?: string;
16
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruckc/truss-chart",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Zero-dependency SVG charts for Truss UI",
5
5
  "type": "module",
6
6
  "license": "MIT",