@ruckc/truss-chart 0.0.0-stage → 1.0.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.
@@ -0,0 +1,207 @@
1
+ <script lang="ts">
2
+ import ChartFrame from './ChartFrame.svelte';
3
+ import Tooltip from './Tooltip.svelte';
4
+ import type { Series } from './types.js';
5
+ import { fmtAxis, fmtValue, linear, niceTicks, seriesColor, MAX_SERIES } from './util.js';
6
+
7
+ let {
8
+ categories,
9
+ series,
10
+ title,
11
+ subtitle,
12
+ height = 240,
13
+ stacked = false,
14
+ horizontal = false,
15
+ showValues = false,
16
+ yFormat = fmtValue
17
+ }: {
18
+ categories: string[];
19
+ /** Up to 8 series (fixed-order palette). Negative values are allowed everywhere, including stacked bars. */
20
+ series: Series[];
21
+ title?: string;
22
+ subtitle?: string;
23
+ /** Plot height in px (horizontal charts grow to fit their categories). */
24
+ height?: number;
25
+ /** `true` stacks values (positives up/right of zero, negatives down/left); `'percent'` normalises each category to 100%. */
26
+ stacked?: boolean | 'percent';
27
+ /** Bars run left→right with categories down the side — better for long labels and many categories. */
28
+ horizontal?: boolean;
29
+ /** Label the end of each bar (the stack total when stacked). */
30
+ showValues?: boolean;
31
+ yFormat?: (n: number) => string;
32
+ } = $props();
33
+
34
+ let hidden = $state<string[]>([]);
35
+ type Tip = { x: number; y: number; cat: string; name: string; value: number; color: string; share?: number };
36
+ let tip = $state<Tip | null>(null);
37
+
38
+ const all = $derived(series.slice(0, MAX_SERIES).map((s, i) => ({ ...s, i, color: seriesColor(i) })));
39
+ const vis = $derived(all.filter((s) => !hidden.includes(s.name)));
40
+ const items = $derived(all.map((s) => ({ name: s.name, color: s.color, hidden: hidden.includes(s.name) })));
41
+ const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
42
+ const percent = $derived(stacked === 'percent');
43
+ const isStack = $derived(!!stacked);
44
+ const fmt = $derived((n: number) => (percent ? `${fmtValue(n)}%` : yFormat(n)));
45
+
46
+ /** One drawn segment: [lo, hi] on the value axis, per category and series. */
47
+ type Seg = { c: number; k: number; lo: number; hi: number; v: number; sign: 1 | -1; outer: boolean; inner: boolean };
48
+ const segs = $derived.by(() => {
49
+ const out: Seg[] = [];
50
+ categories.forEach((_, c) => {
51
+ if (!isStack) {
52
+ vis.forEach((s, k) => {
53
+ const v = s.values[c] ?? 0;
54
+ out.push({ c, k, lo: Math.min(0, v), hi: Math.max(0, v), v, sign: v < 0 ? -1 : 1, outer: true, inner: false });
55
+ });
56
+ return;
57
+ }
58
+ const total = percent ? vis.reduce((a, s) => a + Math.abs(s.values[c] ?? 0), 0) || 1 : 1;
59
+ let pos = 0, neg = 0;
60
+ const posIdx = vis.map((s, k) => ((s.values[c] ?? 0) > 0 ? k : -1)).filter((k) => k >= 0);
61
+ const negIdx = vis.map((s, k) => ((s.values[c] ?? 0) < 0 ? k : -1)).filter((k) => k >= 0);
62
+ vis.forEach((s, k) => {
63
+ const raw = s.values[c] ?? 0;
64
+ if (raw === 0) return;
65
+ const v = percent ? (raw / total) * 100 : raw;
66
+ if (v > 0) { out.push({ c, k, lo: pos, hi: pos + v, v: raw, sign: 1, outer: k === posIdx.at(-1), inner: k !== posIdx[0] }); pos += v; }
67
+ else { out.push({ c, k, lo: neg + v, hi: neg, v: raw, sign: -1, outer: k === negIdx.at(-1), inner: k !== negIdx[0] }); neg += v; }
68
+ });
69
+ });
70
+ return out;
71
+ });
72
+ const scale = $derived.by(() => {
73
+ let lo = 0, hi = 0;
74
+ for (const s of segs) { lo = Math.min(lo, s.lo); hi = Math.max(hi, s.hi); }
75
+ if (percent && lo === 0) hi = 100;
76
+ return niceTicks(lo, hi || 1, 5);
77
+ });
78
+ const totals = $derived(
79
+ categories.map((_, c) => vis.reduce((a, s) => a + (s.values[c] ?? 0), 0))
80
+ );
81
+
82
+ /** Rounded rectangle with only the chosen corners rounded. */
83
+ function rr(x: number, y: number, w: number, h: number, r: number, c: { tl?: boolean; tr?: boolean; br?: boolean; bl?: boolean }) {
84
+ if (w <= 0 || h <= 0) return '';
85
+ const k = Math.min(r, w / 2, h / 2);
86
+ const tl = c.tl ? k : 0, tr = c.tr ? k : 0, br = c.br ? k : 0, bl = c.bl ? k : 0;
87
+ return `M${x + tl},${y}H${x + w - tr}${tr ? `Q${x + w},${y} ${x + w},${y + tr}` : ''}V${y + h - br}${br ? `Q${x + w},${y + h} ${x + w - br},${y + h}` : ''}H${x + bl}${bl ? `Q${x},${y + h} ${x},${y + h - bl}` : ''}V${y + tl}${tl ? `Q${x},${y} ${x + tl},${y}` : ''}Z`;
88
+ }
89
+
90
+ const GAP = 1; // each side of a shared edge yields a 2px surface gap
91
+ const labelWidth = $derived(Math.min(168, Math.max(48, Math.max(0, ...categories.map((c) => c.length)) * 6.4 + 14)));
92
+ const trunc = (s: string, px: number) => (s.length * 6.4 > px ? `${s.slice(0, Math.max(1, Math.floor(px / 6.4) - 1))}…` : s);
93
+ </script>
94
+
95
+ <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
96
+ {#snippet plot(w)}
97
+ {@const m = horizontal ? { top: 10, right: showValues ? 56 : 14, bottom: 26, left: labelWidth } : { top: showValues ? 22 : 12, right: 12, bottom: 28, left: 46 }}
98
+ {@const bandN = Math.max(categories.length, 1)}
99
+ {@const plotH = horizontal ? Math.max(height, bandN * (isStack ? 30 : Math.max(1, vis.length) * 16 + 14) + m.top + m.bottom) : height}
100
+ {@const V = horizontal ? linear(scale.min, scale.max, m.left, w - m.right) : linear(scale.min, scale.max, plotH - m.bottom, m.top)}
101
+ {@const band = horizontal ? (plotH - m.top - m.bottom) / bandN : (w - m.left - m.right) / bandN}
102
+ {@const groupT = band * (horizontal ? 0.72 : 0.7)}
103
+ {@const nb = Math.max(1, vis.length)}
104
+ {@const thick = isStack ? Math.min(groupT, horizontal ? 28 : 56) : Math.max(4, Math.min(horizontal ? 22 : 40, (groupT - 2 * (nb - 1)) / nb))}
105
+ {@const cluster = isStack ? thick : thick * nb + 2 * (nb - 1)}
106
+ {@const catPos = (c: number) => (horizontal ? m.top : m.left) + band * c + band / 2}
107
+
108
+ <svg width={w} height={plotH} role="img" aria-label="{title ?? 'Bar chart'}. A table view is available." onpointerleave={() => (tip = null)}>
109
+ {#each scale.ticks as t}
110
+ {#if horizontal}
111
+ <line class="grid" x1={V(t)} x2={V(t)} y1={m.top} y2={plotH - m.bottom} data-zero={t === 0} />
112
+ <text class="tick" x={V(t)} y={plotH - 8} text-anchor="middle">{percent ? `${t}%` : fmtAxis(t)}</text>
113
+ {:else}
114
+ <line class="grid" x1={m.left} x2={w - m.right} y1={V(t)} y2={V(t)} data-zero={t === 0} />
115
+ <text class="tick" x={m.left - 8} y={V(t)} text-anchor="end" dominant-baseline="middle">{percent ? `${t}%` : fmtAxis(t)}</text>
116
+ {/if}
117
+ {/each}
118
+
119
+ {#each categories as cat, c}
120
+ {#if horizontal}
121
+ <text class="tick cat" x={m.left - 8} y={catPos(c)} text-anchor="end" dominant-baseline="middle">{trunc(cat, m.left - 14)}<title>{cat}</title></text>
122
+ {:else}
123
+ <text class="tick" x={catPos(c)} y={plotH - 8} text-anchor="middle">{cat}</text>
124
+ {/if}
125
+ {/each}
126
+
127
+ {#each segs as s (`${s.c}:${s.k}`)}
128
+ {@const ser = vis[s.k]}
129
+ {@const off = isStack ? 0 : s.k * (thick + 2)}
130
+ {@const start = catPos(s.c) - cluster / 2 + off}
131
+ {@const p0 = V(s.lo)}
132
+ {@const p1 = V(s.hi)}
133
+ <!-- neighbours: toward zero (inner) and away from zero (not the outermost) — each gives up 1px so the gap is 2px -->
134
+ {@const gapLo = isStack && (s.sign === 1 ? s.inner : !s.outer)}
135
+ {@const gapHi = isStack && (s.sign === 1 ? !s.outer : s.inner)}
136
+ {@const a = Math.min(p0, p1) + (horizontal ? (gapLo ? GAP : 0) : gapHi ? GAP : 0)}
137
+ {@const bEnd = Math.max(p0, p1) - (horizontal ? (gapHi ? GAP : 0) : gapLo ? GAP : 0)}
138
+ {@const d = horizontal
139
+ ? rr(a, start, bEnd - a, thick, 4, s.outer ? (s.sign === 1 ? { tr: true, br: true } : { tl: true, bl: true }) : {})
140
+ : rr(start, a, thick, bEnd - a, 4, s.outer ? (s.sign === 1 ? { tl: true, tr: true } : { bl: true, br: true }) : {})}
141
+ {@const tx = horizontal ? (s.sign === 1 ? Math.max(p0, p1) : Math.min(p0, p1)) : start + thick / 2}
142
+ {@const ty = horizontal ? start + thick / 2 : s.sign === 1 ? Math.min(p0, p1) : Math.max(p0, p1)}
143
+ {#if d}
144
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
145
+ <path
146
+ class="bar" fill={ser.color} role="img" tabindex="0" data-cat={categories[s.c]} data-series={ser.name}
147
+ aria-label="{categories[s.c]}, {ser.name}: {yFormat(s.v)}{percent ? ` (${fmtValue(Math.abs(s.hi - s.lo))}%)` : ''}"
148
+ {d}
149
+ onpointermove={() => (tip = { x: tx, y: ty, cat: categories[s.c], name: ser.name, value: s.v, color: ser.color, share: percent ? Math.abs(s.hi - s.lo) : undefined })}
150
+ onfocus={() => (tip = { x: tx, y: ty, cat: categories[s.c], name: ser.name, value: s.v, color: ser.color, share: percent ? Math.abs(s.hi - s.lo) : undefined })}
151
+ onblur={() => (tip = null)}
152
+ />
153
+ {/if}
154
+ {/each}
155
+
156
+ {#if showValues && !percent}
157
+ {#each categories as cat, c}
158
+ {#if isStack || vis.length}
159
+ {#each isStack ? [{ v: totals[c], k: 0 }] : vis.map((s, k) => ({ v: s.values[c] ?? 0, k })) as lab}
160
+ {@const end = isStack ? V(lab.v >= 0 ? Math.max(0, ...segs.filter((x) => x.c === c).map((x) => x.hi)) : Math.min(0, ...segs.filter((x) => x.c === c).map((x) => x.lo))) : V(lab.v)}
161
+ {@const pos = catPos(c) - cluster / 2 + (isStack ? 0 : lab.k * (thick + 2)) + thick / 2}
162
+ {#if horizontal}
163
+ <text class="val" x={end + (lab.v >= 0 ? 6 : -6)} y={pos} text-anchor={lab.v >= 0 ? 'start' : 'end'} dominant-baseline="middle">{yFormat(lab.v)}</text>
164
+ {:else}
165
+ <text class="val" x={pos} y={end + (lab.v >= 0 ? -6 : 14)} text-anchor="middle">{yFormat(lab.v)}</text>
166
+ {/if}
167
+ {/each}
168
+ {/if}
169
+ {/each}
170
+ {/if}
171
+ </svg>
172
+
173
+ {#if tip}
174
+ <Tooltip x={tip.x} y={tip.y} width={w}>
175
+ <div class="head">{tip.cat}</div>
176
+ <div class="row"><span class="line-key" style:background={tip.color}></span><b>{yFormat(tip.value)}</b><span class="name">{tip.name}{tip.share != null ? ` · ${fmtValue(tip.share)}%` : ''}</span></div>
177
+ </Tooltip>
178
+ {/if}
179
+ {/snippet}
180
+
181
+ {#snippet table()}
182
+ <table>
183
+ <thead><tr><th>Category</th>{#each all as s}<th>{s.name}</th>{/each}</tr></thead>
184
+ <tbody>
185
+ {#each categories as c, i}
186
+ <tr><td>{c}</td>{#each all as s}<td>{s.values[i] == null ? '—' : yFormat(s.values[i]!)}</td>{/each}</tr>
187
+ {/each}
188
+ </tbody>
189
+ </table>
190
+ {/snippet}
191
+ </ChartFrame>
192
+
193
+ <style>
194
+ svg { display: block; overflow: visible; }
195
+ .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; }
196
+ .grid[data-zero='true'] { stroke-opacity: 1; }
197
+ .tick { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); font-variant-numeric: tabular-nums; }
198
+ .val { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); font-weight: 600; font-variant-numeric: tabular-nums; }
199
+ .bar { outline: none; transition: filter var(--tr-dur-fast) var(--tr-ease); }
200
+ .bar:hover, .bar:focus-visible { filter: brightness(1.12) saturate(1.1); }
201
+ .bar:focus-visible { stroke: var(--tr-on-surface); stroke-width: 2; }
202
+ .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
203
+ .row { display: flex; align-items: center; gap: 8px; line-height: 1.5; }
204
+ .row b { color: var(--tr-on-surface); font-variant-numeric: tabular-nums; }
205
+ .name { color: var(--tr-on-surface-variant); }
206
+ .line-key { width: 12px; height: 8px; border-radius: 2px; flex: none; }
207
+ </style>
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { LegendItem } from './types.js';
4
+
5
+ let {
6
+ title,
7
+ subtitle,
8
+ items = [],
9
+ ontoggle,
10
+ plot,
11
+ table
12
+ }: {
13
+ title?: string;
14
+ subtitle?: string;
15
+ /** Legend is shown for 2+ items; clicking toggles visibility. */
16
+ items?: LegendItem[];
17
+ ontoggle?: (name: string) => void;
18
+ plot: Snippet<[width: number]>;
19
+ /** Table alternative — every value reachable without hover. */
20
+ table: Snippet;
21
+ } = $props();
22
+
23
+ let width = $state(640);
24
+ let showTable = $state(false);
25
+ </script>
26
+
27
+ <figure class="rdc">
28
+ <figcaption>
29
+ <div>
30
+ {#if title}<div class="title">{title}</div>{/if}
31
+ {#if subtitle}<div class="sub">{subtitle}</div>{/if}
32
+ </div>
33
+ <button type="button" class="tv" aria-pressed={showTable} onclick={() => (showTable = !showTable)}>
34
+ {showTable ? 'Chart' : 'Table'}
35
+ </button>
36
+ </figcaption>
37
+
38
+ {#if items.length > 1}
39
+ <ul class="legend" aria-label="Legend">
40
+ {#each items as it (it.name)}
41
+ <li>
42
+ <button type="button" aria-pressed={!it.hidden} onclick={() => ontoggle?.(it.name)}>
43
+ <span class="key" style:background={it.color}></span>{it.name}
44
+ </button>
45
+ </li>
46
+ {/each}
47
+ </ul>
48
+ {/if}
49
+
50
+ <div class="body" bind:clientWidth={width}>
51
+ {#if showTable}<div class="tbl">{@render table()}</div>{:else}{@render plot(width)}{/if}
52
+ </div>
53
+ </figure>
54
+
55
+ <style>
56
+ .rdc { margin: 0; padding: 12px 12px 8px; 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); }
57
+ figcaption { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
58
+ .title { font-weight: 700; }
59
+ .sub { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); }
60
+ .tv { height: 24px; padding: 0 10px; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); font: 600 var(--tr-text-sm) var(--tr-font-sans); cursor: pointer; }
61
+ .tv:hover { background: var(--tr-surface-container-high); }
62
+ .tv:focus-visible, .legend button:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
63
+ .legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 0 0 6px; padding: 0; list-style: none; }
64
+ .legend button { display: inline-flex; align-items: center; gap: 6px; padding: 2px 4px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); font: var(--tr-text-sm) var(--tr-font-sans); cursor: pointer; }
65
+ .legend button[aria-pressed='false'] { opacity: 0.45; text-decoration: line-through; }
66
+ .key { width: 10px; height: 10px; border-radius: 2px; }
67
+ .body { position: relative; }
68
+ .tbl { max-height: 320px; overflow: auto; }
69
+ .tbl :global(table) { width: 100%; border-collapse: collapse; font-size: var(--tr-text-sm); font-variant-numeric: tabular-nums; }
70
+ .tbl :global(th), .tbl :global(td) { padding: 4px 8px; text-align: right; border-bottom: 1px solid var(--tr-outline-variant); }
71
+ .tbl :global(th:first-child), .tbl :global(td:first-child) { text-align: left; }
72
+ .tbl :global(th) { position: sticky; top: 0; background: var(--tr-surface-container); color: var(--tr-on-surface-variant); }
73
+ </style>
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import ChartFrame from './ChartFrame.svelte';
3
+ import { fmtValue, seriesColor, MAX_SERIES } from './util.js';
4
+
5
+ let {
6
+ slices,
7
+ title,
8
+ subtitle,
9
+ size = 200,
10
+ format = fmtValue,
11
+ centerLabel = 'Total'
12
+ }: {
13
+ slices: { name: string; value: number }[];
14
+ title?: string;
15
+ subtitle?: string;
16
+ size?: number;
17
+ format?: (n: number) => string;
18
+ centerLabel?: string;
19
+ } = $props();
20
+
21
+ let hidden = $state<string[]>([]);
22
+ let active = $state<string | null>(null);
23
+
24
+ // Past 8 slices, the tail folds into a muted "Other" — never a generated hue.
25
+ const base = $derived.by(() => {
26
+ const sorted = slices.map((s, i) => ({ ...s, i }));
27
+ if (sorted.length <= MAX_SERIES) return sorted.map((s) => ({ ...s, color: seriesColor(s.i) }));
28
+ const head = sorted.slice(0, MAX_SERIES - 1).map((s) => ({ ...s, color: seriesColor(s.i) }));
29
+ const rest = sorted.slice(MAX_SERIES - 1).reduce((a, s) => a + s.value, 0);
30
+ return [...head, { name: 'Other', value: rest, i: MAX_SERIES - 1, color: 'var(--tr-outline)' }];
31
+ });
32
+ const vis = $derived(base.filter((s) => !hidden.includes(s.name)));
33
+ const total = $derived(vis.reduce((a, s) => a + s.value, 0));
34
+ const items = $derived(base.map((s) => ({ name: s.name, color: s.color, hidden: hidden.includes(s.name) })));
35
+ const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
36
+
37
+ const r = $derived(size / 2 - 6);
38
+ const th = $derived(Math.max(14, size * 0.14));
39
+ const arcs = $derived.by(() => {
40
+ let a0 = -Math.PI / 2;
41
+ return vis.map((s) => {
42
+ const sweep = total ? (s.value / total) * Math.PI * 2 : 0;
43
+ const a1 = a0 + sweep;
44
+ const large = sweep > Math.PI ? 1 : 0;
45
+ const R = r, ri = r - th;
46
+ const pt = (rad: number, a: number) => `${size / 2 + rad * Math.cos(a)},${size / 2 + rad * Math.sin(a)}`;
47
+ const d = sweep >= Math.PI * 2 - 1e-6
48
+ ? `M${pt(R, a0)}A${R},${R} 0 1 1 ${pt(R, a0 + Math.PI)}A${R},${R} 0 1 1 ${pt(R, a0)}M${pt(ri, a0)}A${ri},${ri} 0 1 0 ${pt(ri, a0 + Math.PI)}A${ri},${ri} 0 1 0 ${pt(ri, a0)}Z`
49
+ : `M${pt(R, a0)}A${R},${R} 0 ${large} 1 ${pt(R, a1)}L${pt(ri, a1)}A${ri},${ri} 0 ${large} 0 ${pt(ri, a0)}Z`;
50
+ a0 = a1;
51
+ return { ...s, d };
52
+ });
53
+ });
54
+ const focus = $derived(vis.find((s) => s.name === active));
55
+ </script>
56
+
57
+ <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
58
+ {#snippet plot()}
59
+ <div class="wrap">
60
+ <svg width={size} height={size} role="img" aria-label="{title ?? 'Donut chart'}. A table view is available.">
61
+ {#each arcs as a (a.name)}
62
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
63
+ <path
64
+ role="img" d={a.d} fill={a.color} fill-rule="evenodd" class="slice" data-dim={active != null && active !== a.name} tabindex="0"
65
+ aria-label="{a.name}: {format(a.value)} ({Math.round((a.value / total) * 100)}%)"
66
+ onpointerenter={() => (active = a.name)} onpointerleave={() => (active = null)}
67
+ onfocus={() => (active = a.name)} onblur={() => (active = null)}
68
+ />
69
+ {/each}
70
+ <text class="big" x={size / 2} y={size / 2 - 2} text-anchor="middle">{format(focus?.value ?? total)}</text>
71
+ <text class="small" x={size / 2} y={size / 2 + 16} text-anchor="middle">
72
+ {focus ? `${focus.name} · ${Math.round((focus.value / total) * 100)}%` : centerLabel}
73
+ </text>
74
+ </svg>
75
+ </div>
76
+ {/snippet}
77
+
78
+ {#snippet table()}
79
+ <table>
80
+ <thead><tr><th>Slice</th><th>Value</th><th>Share</th></tr></thead>
81
+ <tbody>
82
+ {#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}
83
+ </tbody>
84
+ </table>
85
+ {/snippet}
86
+ </ChartFrame>
87
+
88
+ <style>
89
+ .wrap { display: flex; justify-content: center; padding: 8px 0; }
90
+ /* 2px surface gap between adjacent fills */
91
+ .slice { stroke: var(--tr-surface-container-low); stroke-width: 2; outline: none; transition: opacity var(--tr-dur-fast) var(--tr-ease); }
92
+ .slice[data-dim='true'] { opacity: 0.4; }
93
+ .slice:focus-visible { stroke: var(--tr-on-surface); }
94
+ .big { fill: var(--tr-on-surface); font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
95
+ .small { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); }
96
+ </style>
@@ -0,0 +1,164 @@
1
+ <script lang="ts">
2
+ import ChartFrame from './ChartFrame.svelte';
3
+ import Tooltip from './Tooltip.svelte';
4
+ import { barPath, fmtAxis, fmtValue, linear, niceTicks, seriesColor } from './util.js';
5
+
6
+ let {
7
+ values,
8
+ bins = 'auto',
9
+ domain,
10
+ title,
11
+ subtitle,
12
+ height = 240,
13
+ color = seriesColor(0),
14
+ xFormat = fmtValue,
15
+ range = $bindable(null),
16
+ onbrush
17
+ }: {
18
+ values: number[];
19
+ /** Approximate bin count, or 'auto' (Sturges). Edges snap to nice numbers. */
20
+ bins?: number | 'auto';
21
+ domain?: [number, number];
22
+ title?: string;
23
+ subtitle?: string;
24
+ height?: number;
25
+ color?: string;
26
+ xFormat?: (n: number) => string;
27
+ /** Brushed selection [from, to] on the value axis (snaps to bin edges). Drag to select, click to clear. */
28
+ range?: [number, number] | null;
29
+ onbrush?: (range: [number, number] | null) => void;
30
+ } = $props();
31
+
32
+ let hover = $state<number | null>(null);
33
+ let drag = $state<{ a: number; b: number } | null>(null);
34
+
35
+ const clean = $derived(values.filter((v) => Number.isFinite(v)));
36
+ const want = $derived(bins === 'auto' ? Math.min(60, Math.max(5, Math.ceil(Math.log2(Math.max(clean.length, 1)) + 1))) : bins);
37
+ const ext = $derived<[number, number]>(domain ?? [Math.min(...clean, 0), Math.max(...clean, 1)]);
38
+ const grid = $derived(niceTicks(ext[0], ext[1], want));
39
+ const edges = $derived(grid.ticks);
40
+ const nb = $derived(Math.max(1, edges.length - 1));
41
+ const step = $derived((grid.max - grid.min) / nb);
42
+ const counts = $derived.by(() => {
43
+ const c = new Array(nb).fill(0);
44
+ for (const v of clean) {
45
+ if (v < grid.min || v > grid.max) continue;
46
+ c[Math.min(nb - 1, Math.floor((v - grid.min) / step))]++;
47
+ }
48
+ return c as number[];
49
+ });
50
+ const yScale = $derived(niceTicks(0, Math.max(...counts, 1), 5));
51
+ const total = $derived(counts.reduce((a, b) => a + b, 0));
52
+
53
+ const inRange = (i: number) => !range || (edges[i + 1] > range[0] && edges[i] < range[1]);
54
+ const sel = $derived(drag ? ([Math.min(drag.a, drag.b), Math.max(drag.a, drag.b)] as [number, number]) : range);
55
+
56
+ function set(r: [number, number] | null) {
57
+ range = r;
58
+ onbrush?.(r);
59
+ }
60
+ const snapLo = (v: number) => grid.min + Math.floor((v - grid.min) / step + 1e-9) * step;
61
+ const snapHi = (v: number) => grid.min + Math.ceil((v - grid.min) / step - 1e-9) * step;
62
+ </script>
63
+
64
+ <ChartFrame {title} {subtitle}>
65
+ {#snippet plot(w)}
66
+ {@const m = { top: 12, right: 12, bottom: 28, left: 46 }}
67
+ {@const X = linear(grid.min, grid.max, m.left, w - m.right)}
68
+ {@const Y = linear(yScale.min, yScale.max, height - m.bottom, m.top)}
69
+ {@const bw = Math.max(2, (X(grid.min + step) - X(grid.min)) - 2)}
70
+ {@const toVal = (px: number) => grid.min + ((px - m.left) / (w - m.left - m.right)) * (grid.max - grid.min)}
71
+ {@const xt = edges.length > 12 ? edges.filter((_, i) => i % Math.ceil(edges.length / 8) === 0) : edges}
72
+
73
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
74
+ <svg
75
+ width={w} {height} role="group" tabindex="0"
76
+ aria-label="{title ?? 'Histogram'}. Arrow keys move between bins, Enter selects a bin, Escape clears the selection."
77
+ onpointerleave={() => (hover = null)}
78
+ onblur={() => (hover = null)}
79
+ onkeydown={(e) => {
80
+ if (e.key === 'ArrowRight') hover = Math.min(nb - 1, (hover ?? -1) + 1);
81
+ else if (e.key === 'ArrowLeft') hover = Math.max(0, (hover ?? nb) - 1);
82
+ else if (e.key === 'Enter' && hover != null) set(e.shiftKey && range ? [Math.min(range[0], edges[hover]), Math.max(range[1], edges[hover + 1])] : [edges[hover], edges[hover + 1]]);
83
+ else if (e.key === 'Escape') set(null);
84
+ else return;
85
+ e.preventDefault();
86
+ }}
87
+ >
88
+ {#each yScale.ticks as t}
89
+ <line class="grid" x1={m.left} x2={w - m.right} y1={Y(t)} y2={Y(t)} />
90
+ <text class="tick" x={m.left - 8} y={Y(t)} text-anchor="end" dominant-baseline="middle">{fmtAxis(t)}</text>
91
+ {/each}
92
+ {#each xt as e}
93
+ <text class="tick" x={X(e)} y={height - 8} text-anchor="middle">{xFormat(e)}</text>
94
+ {/each}
95
+
96
+ {#each counts as c, i}
97
+ <path class="bar" d={barPath(X(edges[i]) + 1, Y(0), Y(c), bw, 4)} fill={color} data-dim={!!sel && !(edges[i + 1] > sel[0] && edges[i] < sel[1])} data-hover={hover === i} />
98
+ {/each}
99
+
100
+ {#if sel}
101
+ <rect class="brush" x={X(sel[0])} y={m.top} width={Math.max(0, X(sel[1]) - X(sel[0]))} height={height - m.top - m.bottom} />
102
+ <line class="edge" x1={X(sel[0])} x2={X(sel[0])} y1={m.top} y2={height - m.bottom} />
103
+ <line class="edge" x1={X(sel[1])} x2={X(sel[1])} y1={m.top} y2={height - m.bottom} />
104
+ {/if}
105
+
106
+ <!-- hit layer: hover for tooltip, drag to brush -->
107
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
108
+ <rect
109
+ class="hit" x={m.left} y={m.top} width={w - m.left - m.right} height={height - m.top - m.bottom}
110
+ onpointermove={(e) => {
111
+ const px = e.clientX - e.currentTarget.getBoundingClientRect().left + m.left;
112
+ hover = Math.min(nb - 1, Math.max(0, Math.floor((toVal(px) - grid.min) / step)));
113
+ if (drag) drag = { ...drag, b: Math.min(grid.max, Math.max(grid.min, toVal(px))) };
114
+ }}
115
+ onpointerdown={(e) => {
116
+ e.currentTarget.setPointerCapture(e.pointerId);
117
+ const v = toVal(e.clientX - e.currentTarget.getBoundingClientRect().left + m.left);
118
+ drag = { a: v, b: v };
119
+ }}
120
+ onpointerup={() => {
121
+ if (!drag) return;
122
+ const lo = Math.min(drag.a, drag.b), hi = Math.max(drag.a, drag.b);
123
+ set(Math.abs(X(hi) - X(lo)) < 4 ? null : [snapLo(lo), snapHi(hi)]);
124
+ drag = null;
125
+ }}
126
+ />
127
+ </svg>
128
+
129
+ {#if hover != null && !drag}
130
+ <Tooltip x={X(edges[hover] + step / 2)} y={Y(counts[hover]) - 4} width={w}>
131
+ <div class="head">{xFormat(edges[hover])} – {xFormat(edges[hover + 1])}</div>
132
+ <div class="row"><b>{counts[hover]}</b><span class="name">{total ? Math.round((counts[hover] / total) * 100) : 0}% of {total}</span></div>
133
+ </Tooltip>
134
+ {/if}
135
+ {/snippet}
136
+
137
+ {#snippet table()}
138
+ <table>
139
+ <thead><tr><th>Range</th><th>Count</th><th>Share</th></tr></thead>
140
+ <tbody>
141
+ {#each counts as c, i}
142
+ <tr><td>{xFormat(edges[i])} – {xFormat(edges[i + 1])}</td><td>{c}</td><td>{total ? Math.round((c / total) * 100) : 0}%</td></tr>
143
+ {/each}
144
+ </tbody>
145
+ </table>
146
+ {/snippet}
147
+ </ChartFrame>
148
+
149
+ <style>
150
+ svg { display: block; outline: none; overflow: visible; }
151
+ svg:focus-visible { border-radius: var(--tr-radius-sm); box-shadow: var(--tr-focus-ring); }
152
+ .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; }
153
+ .tick { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); font-variant-numeric: tabular-nums; }
154
+ .bar { transition: opacity var(--tr-dur-fast) var(--tr-ease), filter var(--tr-dur-fast) var(--tr-ease); }
155
+ .bar[data-hover='true'] { filter: brightness(1.12) saturate(1.1); }
156
+ .bar[data-dim='true'] { opacity: 0.3; }
157
+ .brush { fill: var(--tr-primary); fill-opacity: 0.1; pointer-events: none; }
158
+ .edge { stroke: var(--tr-primary); stroke-width: 1.5; pointer-events: none; }
159
+ .hit { fill: transparent; cursor: crosshair; touch-action: none; }
160
+ .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
161
+ .row { display: flex; align-items: baseline; gap: 8px; }
162
+ .row b { color: var(--tr-on-surface); font-variant-numeric: tabular-nums; }
163
+ .name { color: var(--tr-on-surface-variant); }
164
+ </style>
@@ -0,0 +1,157 @@
1
+ <script lang="ts">
2
+ import ChartFrame from './ChartFrame.svelte';
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';
6
+
7
+ let {
8
+ x,
9
+ series,
10
+ title,
11
+ subtitle,
12
+ height = 240,
13
+ area = false,
14
+ zeroBaseline = false,
15
+ directLabels = true,
16
+ yFormat = fmtValue
17
+ }: {
18
+ /** Shared x positions: numbers or Dates. */
19
+ x: (number | Date)[];
20
+ /** Max 8 (fixed-order palette, never cycled). Fold extras into "Other" upstream. */
21
+ series: Series[];
22
+ title?: string;
23
+ subtitle?: string;
24
+ height?: number;
25
+ area?: boolean;
26
+ zeroBaseline?: boolean;
27
+ /** Name labels at line ends when ≤ 4 series. */
28
+ directLabels?: boolean;
29
+ yFormat?: (n: number) => string;
30
+ } = $props();
31
+
32
+ let hidden = $state<string[]>([]);
33
+ let hover = $state<number | null>(null);
34
+
35
+ const all = $derived(series.slice(0, MAX_SERIES).map((s, i) => ({ ...s, i, color: seriesColor(i) })));
36
+ const vis = $derived(all.filter((s) => !hidden.includes(s.name)));
37
+ const isDate = $derived(x[0] instanceof Date);
38
+ const xs = $derived(x.map((v) => +v));
39
+ const labelled = $derived(directLabels && all.length > 1 && all.length <= 4);
40
+ const items = $derived(all.map((s) => ({ name: s.name, color: s.color, hidden: hidden.includes(s.name) })));
41
+ const toggle = (n: string) => (hidden = hidden.includes(n) ? hidden.filter((h) => h !== n) : [...hidden, n]);
42
+
43
+ const yScale = $derived.by(() => {
44
+ 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); }
46
+ if (!isFinite(lo)) { lo = 0; hi = 1; }
47
+ if (area || zeroBaseline) lo = Math.min(lo, 0);
48
+ return niceTicks(lo, hi, 5);
49
+ });
50
+
51
+ function segments(values: (number | null)[]) {
52
+ const out: number[][] = [];
53
+ let cur: number[] = [];
54
+ values.forEach((v, i) => { if (v == null) { if (cur.length) out.push(cur); cur = []; } else cur.push(i); });
55
+ if (cur.length) out.push(cur);
56
+ return out;
57
+ }
58
+ </script>
59
+
60
+ <ChartFrame {title} {subtitle} {items} ontoggle={toggle}>
61
+ {#snippet plot(w)}
62
+ {@const m = { top: 12, right: labelled ? 92 : 14, bottom: 26, left: 46 }}
63
+ {@const X = linear(Math.min(...xs), Math.max(...xs), m.left, w - m.right)}
64
+ {@const Y = linear(yScale.min, yScale.max, height - m.bottom, m.top)}
65
+ {@const span = Math.max(...xs) - Math.min(...xs)}
66
+ {@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)}
68
+ {@const labelY = spreadLabels(ends.map((e) => e.y), 14, m.top + 4, height - m.bottom)}
69
+
70
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
71
+ <svg
72
+ width={w} {height} role="group" tabindex="0"
73
+ aria-label="{title ?? 'Line chart'}. Use left and right arrow keys to read values."
74
+ onpointermove={(e) => {
75
+ const px = e.clientX - e.currentTarget.getBoundingClientRect().left;
76
+ let best = 0;
77
+ for (let i = 1; i < xs.length; i++) if (Math.abs(X(xs[i]) - px) < Math.abs(X(xs[best]) - px)) best = i;
78
+ hover = best;
79
+ }}
80
+ onpointerleave={() => (hover = null)}
81
+ onblur={() => (hover = null)}
82
+ onkeydown={(e) => {
83
+ if (e.key === 'ArrowRight') hover = Math.min(xs.length - 1, (hover ?? -1) + 1);
84
+ else if (e.key === 'ArrowLeft') hover = Math.max(0, (hover ?? xs.length) - 1);
85
+ else if (e.key === 'Escape') hover = null;
86
+ else return;
87
+ e.preventDefault();
88
+ }}
89
+ >
90
+ {#each yScale.ticks as t}
91
+ <line class="grid" x1={m.left} x2={w - m.right} y1={Y(t)} y2={Y(t)} />
92
+ <text class="tick" x={m.left - 8} y={Y(t)} text-anchor="end" dominant-baseline="middle">{fmtAxis(t)}</text>
93
+ {/each}
94
+ {#each xTicks as t}
95
+ <text class="tick" x={X(t)} y={height - 8} text-anchor="middle">{fmtX(t, span, isDate)}</text>
96
+ {/each}
97
+
98
+ {#each vis as s (s.name)}
99
+ {#if area}
100
+ {#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" />
103
+ {/each}
104
+ {/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(' ')} />
106
+ {/each}
107
+
108
+ {#each ends as e, k}
109
+ {#if labelled}<text class="direct" x={w - m.right + 8} y={labelY[k]} dominant-baseline="middle">{e.s.name}</text>{/if}
110
+ {/each}
111
+
112
+ {#if hover != null}
113
+ <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}
116
+ {/each}
117
+ {/if}
118
+ </svg>
119
+
120
+ {#if hover != null}
121
+ <Tooltip x={X(xs[hover])} y={height / 2} width={w}>
122
+ <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>
125
+ {/each}
126
+ </Tooltip>
127
+ {/if}
128
+ {/snippet}
129
+
130
+ {#snippet table()}
131
+ <table>
132
+ <thead><tr><th>{isDate ? 'Date' : 'X'}</th>{#each all as s}<th>{s.name}</th>{/each}</tr></thead>
133
+ <tbody>
134
+ {#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>
136
+ {/each}
137
+ </tbody>
138
+ </table>
139
+ {/snippet}
140
+ </ChartFrame>
141
+
142
+ <style>
143
+ svg { display: block; outline: none; overflow: visible; }
144
+ svg:focus-visible { border-radius: var(--tr-radius-sm); box-shadow: var(--tr-focus-ring); }
145
+ .grid { stroke: var(--tr-outline-variant); stroke-opacity: 0.6; stroke-width: 1; }
146
+ .tick { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); font-variant-numeric: tabular-nums; }
147
+ .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
148
+ .area { fill-opacity: 0.14; }
149
+ .direct { fill: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); font-weight: 600; }
150
+ .cross { stroke: var(--tr-outline); stroke-width: 1; stroke-dasharray: 3 3; }
151
+ .dot { stroke: var(--tr-surface-container-low); stroke-width: 2; }
152
+ .head { color: var(--tr-on-surface-variant); margin-bottom: 4px; }
153
+ .row { display: flex; align-items: center; gap: 8px; line-height: 1.5; }
154
+ .row b { color: var(--tr-on-surface); font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; }
155
+ .name { color: var(--tr-on-surface-variant); }
156
+ .line-key { width: 12px; height: 2px; border-radius: 1px; flex: none; }
157
+ </style>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { linear, seriesColor } from './util.js';
3
+
4
+ let {
5
+ values,
6
+ width = 120,
7
+ height = 28,
8
+ color = seriesColor(0),
9
+ label = 'Trend'
10
+ }: { values: (number | null)[]; width?: number; height?: number; color?: string; label?: string } = $props();
11
+
12
+ const pts = $derived(values.map((v, i) => ({ v, i })).filter((p) => p.v != null) as { v: number; i: number }[]);
13
+ const X = $derived(linear(0, Math.max(values.length - 1, 1), 3, width - 3));
14
+ const Y = $derived(linear(Math.min(...pts.map((p) => p.v)), Math.max(...pts.map((p) => p.v)), height - 4, 4));
15
+ const last = $derived(pts.at(-1));
16
+ </script>
17
+
18
+ <svg {width} {height} role="img" aria-label="{label}: {pts.map((p) => p.v).join(', ')}">
19
+ <path d={pts.map((p, k) => `${k ? 'L' : 'M'}${X(p.i).toFixed(1)},${Y(p.v).toFixed(1)}`).join(' ')} fill="none" stroke={color} stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
20
+ {#if last}<circle cx={X(last.i)} cy={Y(last.v)} r="3.5" fill={color} stroke="var(--tr-surface-container-low)" stroke-width="2" />{/if}
21
+ </svg>
22
+
23
+ <style>svg { display: inline-block; vertical-align: middle; overflow: visible; }</style>
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ /** Positioned inside a `position: relative` host. Values lead, names follow, line keys not boxes. */
5
+ let { x, y, width, children }: { x: number; y: number; width: number; children: Snippet } = $props();
6
+ </script>
7
+
8
+ <div class="tip" role="status" style:left="{x}px" style:top="{y}px" data-flip={x > width * 0.6}>{@render children()}</div>
9
+
10
+ <style>
11
+ .tip { position: absolute; z-index: 5; transform: translate(12px, -50%); min-width: 120px; padding: 6px 10px; pointer-events: none; background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-2); font-size: var(--tr-text-sm); white-space: nowrap; }
12
+ .tip[data-flip='true'] { transform: translate(calc(-100% - 12px), -50%); }
13
+ </style>
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Categorical series palette (8 slots, fixed order — never cycled).
3
+ * Validated adjacent-pair CVD / normal-vision separation in both modes.
4
+ * Chart chrome reuses truss tokens, so it themes with the rest of the app.
5
+ */
6
+ :root {
7
+ --trc-s1: #2a78d6; --trc-s2: #eb6834; --trc-s3: #1baf7a; --trc-s4: #eda100;
8
+ --trc-s5: #e87ba4; --trc-s6: #008300; --trc-s7: #6250d6; --trc-s8: #e34948;
9
+ }
10
+ @media (prefers-color-scheme: dark) {
11
+ :root:not([data-theme='light']) {
12
+ --trc-s1: #3987e5; --trc-s2: #d95926; --trc-s3: #199e70; --trc-s4: #c98500;
13
+ --trc-s5: #d55181; --trc-s6: #008300; --trc-s7: #9085e9; --trc-s8: #e66767;
14
+ }
15
+ }
16
+ :root[data-theme='dark'] {
17
+ --trc-s1: #3987e5; --trc-s2: #d95926; --trc-s3: #199e70; --trc-s4: #c98500;
18
+ --trc-s5: #d55181; --trc-s6: #008300; --trc-s7: #9085e9; --trc-s8: #e66767;
19
+ }
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { default as LineChart } from './LineChart.svelte';
2
+ export { default as BarChart } from './BarChart.svelte';
3
+ export { default as DonutChart } from './DonutChart.svelte';
4
+ export { default as Histogram } from './Histogram.svelte';
5
+ export { default as Sparkline } from './Sparkline.svelte';
6
+ export { seriesColor, niceTicks, linear } from './util.js';
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.js ADDED
@@ -0,0 +1,60 @@
1
+ export const MAX_SERIES = 8;
2
+ /** Color follows the entity (its index in the full series list), never its rank among visible ones. */
3
+ export const seriesColor = (i) => `var(--trc-s${(i % MAX_SERIES) + 1})`;
4
+ export const linear = (d0, d1, r0, r1) => (v) => r0 + ((v - d0) / (d1 - d0 || 1)) * (r1 - r0);
5
+ export function niceTicks(min, max, count = 5) {
6
+ if (min === max) {
7
+ min -= 1;
8
+ max += 1;
9
+ }
10
+ const raw = (max - min) / count;
11
+ const mag = 10 ** Math.floor(Math.log10(raw));
12
+ const n = raw / mag;
13
+ const step = (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * mag;
14
+ const lo = Math.floor(min / step) * step;
15
+ const hi = Math.ceil(max / step) * step;
16
+ const dec = Math.max(0, -Math.floor(Math.log10(step)) + 1);
17
+ const ticks = [];
18
+ for (let v = lo; v <= hi + step / 2; v += step)
19
+ ticks.push(Number(v.toFixed(dec)));
20
+ return { ticks, min: lo, max: hi };
21
+ }
22
+ const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
23
+ const full = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
24
+ export const fmtAxis = (n) => compact.format(n);
25
+ export const fmtValue = (n) => full.format(n);
26
+ export function fmtX(v, span, isDate) {
27
+ if (!isDate)
28
+ return fmtValue(v);
29
+ const d = new Date(v);
30
+ const day = 864e5;
31
+ if (span <= 2 * day)
32
+ return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
33
+ if (span <= 365 * day)
34
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
35
+ return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
36
+ }
37
+ /** Spread label y-positions so they are at least `gap` apart, preserving order. */
38
+ export function spreadLabels(ys, gap, lo, hi) {
39
+ const idx = ys.map((_, i) => i).sort((a, b) => ys[a] - ys[b]);
40
+ const out = [...ys];
41
+ for (let k = 0; k < idx.length; k++) {
42
+ const i = idx[k];
43
+ out[i] = Math.max(k ? out[idx[k - 1]] + gap : lo, out[i]);
44
+ }
45
+ for (let k = idx.length - 1; k >= 0; k--) {
46
+ const i = idx[k];
47
+ out[i] = Math.min(k < idx.length - 1 ? out[idx[k + 1]] - gap : hi, out[i]);
48
+ }
49
+ return out;
50
+ }
51
+ /** Bar with a rounded data-end, square against the baseline. `y0` is the baseline, `y1` the data end. */
52
+ export function barPath(x, y0, y1, w, r, round = true) {
53
+ const top = Math.min(y0, y1), h = Math.abs(y1 - y0);
54
+ if (h < 0.5)
55
+ return '';
56
+ const rr = round ? Math.min(r, w / 2, h) : 0;
57
+ return y1 < y0
58
+ ? `M${x},${y0}V${top + rr}Q${x},${top} ${x + rr},${top}H${x + w - rr}Q${x + w},${top} ${x + w},${top + rr}V${y0}Z`
59
+ : `M${x},${y0}V${top + h - rr}Q${x},${top + h} ${x + rr},${top + h}H${x + w - rr}Q${x + w},${top + h} ${x + w},${top + h - rr}V${y0}Z`;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
- "name": "@ruckc/truss-chart",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@ruckc/truss-chart",
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency SVG charts for Truss UI",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "scripts": {
8
+ "package": "svelte-package -i src/lib -o dist"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.css"
15
+ ],
16
+ "svelte": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "svelte": "./dist/index.js"
22
+ },
23
+ "./charts.css": "./dist/charts.css"
24
+ },
25
+ "peerDependencies": {
26
+ "svelte": "^5.20.0"
27
+ },
28
+ "author": "Curtis Ruck",
29
+ "homepage": "https://truss.ruck.io",
30
+ "bugs": {
31
+ "url": "https://github.com/ruckc/truss-ui/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/ruckc/truss-ui.git",
39
+ "directory": "packages/charts"
40
+ },
41
+ "keywords": [
42
+ "svelte",
43
+ "sveltekit",
44
+ "truss",
45
+ "charts",
46
+ "svg"
47
+ ]
48
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.