@jarenjs/charts 0.34.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.
Files changed (56) hide show
  1. package/README.md +293 -0
  2. package/dist/types/component/index.d.ts +95 -0
  3. package/dist/types/core/axis.d.ts +77 -0
  4. package/dist/types/core/cartesian.d.ts +127 -0
  5. package/dist/types/core/chart.d.ts +58 -0
  6. package/dist/types/core/domain.d.ts +96 -0
  7. package/dist/types/core/marks.d.ts +86 -0
  8. package/dist/types/core/palette.d.ts +72 -0
  9. package/dist/types/core/scale.d.ts +59 -0
  10. package/dist/types/core/session.d.ts +85 -0
  11. package/dist/types/core/stream-adapter.d.ts +187 -0
  12. package/dist/types/index.d.ts +35 -0
  13. package/dist/types/transforms/benchmark-adapter.d.ts +169 -0
  14. package/dist/types/transforms/mermaid-adapter.d.ts +30 -0
  15. package/dist/types/types/bar.d.ts +213 -0
  16. package/dist/types/types/boxplot.d.ts +116 -0
  17. package/dist/types/types/candlestick.d.ts +218 -0
  18. package/dist/types/types/gauge.d.ts +68 -0
  19. package/dist/types/types/heatmap.d.ts +104 -0
  20. package/dist/types/types/line.d.ts +272 -0
  21. package/dist/types/types/map.d.ts +137 -0
  22. package/dist/types/types/pie.d.ts +146 -0
  23. package/dist/types/types/radar.d.ts +89 -0
  24. package/dist/types/types/sankey.d.ts +100 -0
  25. package/dist/types/types/scatter.d.ts +80 -0
  26. package/dist/types/types/streamgraph.d.ts +75 -0
  27. package/dist/types/types/treemap.d.ts +118 -0
  28. package/package.json +76 -0
  29. package/schemas/chart-definition.schema.json +448 -0
  30. package/src/component/index.js +125 -0
  31. package/src/core/axis.js +221 -0
  32. package/src/core/cartesian.js +192 -0
  33. package/src/core/chart.js +101 -0
  34. package/src/core/domain.js +123 -0
  35. package/src/core/marks.js +110 -0
  36. package/src/core/palette.js +126 -0
  37. package/src/core/scale.js +106 -0
  38. package/src/core/session.js +0 -0
  39. package/src/core/stream-adapter.js +613 -0
  40. package/src/index.js +40 -0
  41. package/src/transforms/benchmark-adapter.js +298 -0
  42. package/src/transforms/mermaid-adapter.js +19 -0
  43. package/src/types/bar.js +276 -0
  44. package/src/types/boxplot.js +216 -0
  45. package/src/types/candlestick.js +274 -0
  46. package/src/types/gauge.js +140 -0
  47. package/src/types/heatmap.js +176 -0
  48. package/src/types/line.js +349 -0
  49. package/src/types/map.js +378 -0
  50. package/src/types/pie.js +163 -0
  51. package/src/types/radar.js +224 -0
  52. package/src/types/sankey.js +391 -0
  53. package/src/types/scatter.js +148 -0
  54. package/src/types/streamgraph.js +158 -0
  55. package/src/types/treemap.js +322 -0
  56. package/styles/charts.css +83 -0
@@ -0,0 +1,298 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Benchmark interop: pure functions from the published benchmark
4
+ * data shapes (the jaren website's generated `benchmarks/*.json`) to
5
+ * `compileChart`-shaped `{config, data}` pairs. No formatting, no
6
+ * vnodes, no fetching — data in, chart definition out, so every
7
+ * function is unit-testable against a vendored slice of the real data.
8
+ *
9
+ * The suite-wide ratio convention holds: ratio > 1 means "Jaren is N×
10
+ * faster", and the win/loss semantic tones follow it.
11
+ */
12
+
13
+ /**
14
+ * @typedef {{config: Record<string, any>, data: Record<string, any>}} ChartPair
15
+ */
16
+
17
+ /**
18
+ * The ratio-distribution bar chart: success-only tests bucketed by
19
+ * ratio. Buckets carry their own predicate and tone.
20
+ * @param {{ratio: number|null, isSuccessTest?: boolean}[]} results
21
+ * @param {{label: string, test: (r: number) => boolean, tone: 'win'|'loss'}[]} buckets
22
+ * @param {string} [title]
23
+ * @returns {ChartPair}
24
+ */
25
+ export function ratioDistributionBars(results, buckets, title) {
26
+ const success = results.filter((r) => r.ratio !== null && r.isSuccessTest !== false);
27
+ const counts = buckets.map(() => 0);
28
+ for (const r of success) {
29
+ const index = buckets.findIndex((b) => b.test(/** @type {number} */(r.ratio)));
30
+ if (index >= 0) counts[index] += 1;
31
+ }
32
+ return {
33
+ config: { type: 'bar', title: title ?? null, orient: 'h', valLabel: 'tests' },
34
+ data: {
35
+ categories: buckets.map((b) => b.label),
36
+ series: [{ name: 'tests', values: counts, tones: buckets.map((b) => b.tone) }],
37
+ },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * The ratio scatter: every success-only test as one point, ranked
43
+ * fastest-ratio first, log-scale y, reference line at parity.
44
+ * @param {{ratio: number|null, isSuccessTest?: boolean}[]} results
45
+ * @param {string} [title]
46
+ * @returns {ChartPair}
47
+ */
48
+ export function ratioScatter(results, title) {
49
+ const success = results
50
+ .filter((r) => r.ratio !== null && r.isSuccessTest !== false)
51
+ .map((r) => /** @type {number} */(r.ratio))
52
+ .sort((a, b) => b - a);
53
+ return {
54
+ config: {
55
+ type: 'scatter',
56
+ title: title ?? null,
57
+ yLog: true,
58
+ refY: 1,
59
+ refLabel: '1× (parity)',
60
+ xLabel: 'tests, fastest ratio first',
61
+ yLabel: 'ratio (log)',
62
+ },
63
+ data: {
64
+ points: success.map((ratio, i) => ({
65
+ x: i + 1,
66
+ y: ratio,
67
+ tone: ratio >= 1 ? 'win' : 'loss',
68
+ })),
69
+ },
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Per-draft conformance: grouped bars of passed tests per engine.
75
+ * @param {Record<string, Record<string, {passed: number}>>} engineStats
76
+ * engine → draft → {passed, failed, errors}
77
+ * @param {string} [title]
78
+ * @returns {ChartPair}
79
+ */
80
+ export function conformanceBars(engineStats, title) {
81
+ const engines = Object.keys(engineStats);
82
+ const drafts = Object.keys(engineStats[engines[0]] ?? {});
83
+ return {
84
+ config: { type: 'bar', title: title ?? null, valLabel: 'tests passed' },
85
+ data: {
86
+ categories: drafts,
87
+ series: engines.map((engine) => ({
88
+ name: engine,
89
+ values: drafts.map((draft) => engineStats[engine]?.[draft]?.passed ?? null),
90
+ })),
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Generic engine-comparison bars over `{name, results: {engine: value}}`
97
+ * profile rows (the toml/markdown/mermaid profile shape).
98
+ * @param {{name: string, results: Record<string, number>}[]} rows
99
+ * @param {string[]} engines series order (first = jaren)
100
+ * @param {{title?: string, log?: boolean, valLabel?: string}} [options]
101
+ * @returns {ChartPair}
102
+ */
103
+ export function profileBars(rows, engines, options = {}) {
104
+ return {
105
+ config: {
106
+ type: 'bar',
107
+ title: options.title ?? null,
108
+ log: options.log === true,
109
+ valLabel: options.valLabel ?? null,
110
+ },
111
+ data: {
112
+ categories: rows.map((r) => r.name),
113
+ series: engines.map((engine) => ({
114
+ name: engine,
115
+ values: rows.map((r) => r.results?.[engine] ?? null),
116
+ })),
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Scenario-matrix bars over `{scenario|title, engines: {key: nsPerOp}}`
123
+ * rows (the jsonquery/jslt shape). Log axis — rival engines span orders
124
+ * of magnitude.
125
+ * @param {{scenario?: string, title?: string, engines: Record<string, number>}[]} rows
126
+ * @param {{title?: string, valLabel?: string}} [options]
127
+ * @returns {ChartPair}
128
+ */
129
+ export function matrixBars(rows, options = {}) {
130
+ const keys = [];
131
+ for (const row of rows) {
132
+ for (const key of Object.keys(row.engines ?? {})) {
133
+ if (!keys.includes(key)) keys.push(key);
134
+ }
135
+ }
136
+ keys.sort((a, b) => (a === 'jaren' ? -1 : b === 'jaren' ? 1 : 0));
137
+ return {
138
+ config: {
139
+ type: 'bar',
140
+ title: options.title ?? null,
141
+ log: true,
142
+ valLabel: options.valLabel ?? 'ns/op (log)',
143
+ },
144
+ data: {
145
+ categories: rows.map((r) => r.scenario ?? r.title ?? ''),
146
+ series: keys.map((key) => ({
147
+ name: key,
148
+ values: rows.map((r) => r.engines?.[key] ?? null),
149
+ })),
150
+ },
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Suite pass-count bars over an `{engine: {pass, total}}` map (the
156
+ * toml-compliance / markdown-scorecard shape); the highlighted engine
157
+ * (ours) carries the win tone.
158
+ * @param {Record<string, {pass: number, total: number}>} scorecard
159
+ * @param {{title?: string, highlight?: string, valLabel?: string}} [options]
160
+ * @returns {ChartPair}
161
+ */
162
+ export function passCountBars(scorecard, options = {}) {
163
+ const engines = Object.keys(scorecard);
164
+ return {
165
+ config: {
166
+ type: 'bar',
167
+ title: options.title ?? null,
168
+ orient: 'h',
169
+ valLabel: options.valLabel ?? 'tests passed',
170
+ },
171
+ data: {
172
+ categories: engines,
173
+ series: [{
174
+ name: 'passing',
175
+ values: engines.map((engine) => scorecard[engine]?.pass ?? null),
176
+ tones: engines.map((engine) => engine === options.highlight ? 'win' : null),
177
+ }],
178
+ },
179
+ };
180
+ }
181
+
182
+ /**
183
+ * The jsonpath per-query profile: horizontal grouped bars for the
184
+ * top-N queries by jaren-vs-rival spread (the rest stay in the table).
185
+ * @param {{name: string, engines: Record<string, number>}[]} rows
186
+ * @param {string} rival the comparison engine key (e.g. 'json-p3')
187
+ * @param {number} topN
188
+ * @param {string} [title]
189
+ * @returns {ChartPair}
190
+ */
191
+ export function querySpreadBars(rows, rival, topN, title) {
192
+ const ranked = rows
193
+ .filter((r) => r.engines?.jaren > 0 && r.engines?.[rival] > 0)
194
+ .map((r) => ({ ...r, spread: r.engines[rival] / r.engines.jaren }))
195
+ .sort((a, b) => b.spread - a.spread)
196
+ .slice(0, topN);
197
+ return {
198
+ config: {
199
+ type: 'bar',
200
+ title: title ?? null,
201
+ orient: 'h',
202
+ log: true,
203
+ valLabel: 'ns/op (log)',
204
+ },
205
+ data: {
206
+ categories: ranked.map((r) => r.name),
207
+ series: [
208
+ { name: 'jaren', values: ranked.map((r) => r.engines.jaren) },
209
+ { name: rival, values: ranked.map((r) => r.engines[rival]) },
210
+ ],
211
+ },
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Bars over a pointer/patch-style result table:
217
+ * `{columns, rows: [{name, results: number[]}]}`.
218
+ * @param {{title?: string, columns: string[], rows: {name: string, results: number[]}[]}} table
219
+ * @param {{title?: string, log?: boolean, valLabel?: string}} [options]
220
+ * @returns {ChartPair}
221
+ */
222
+ export function resultTableBars(table, options = {}) {
223
+ return {
224
+ config: {
225
+ type: 'bar',
226
+ title: options.title ?? table.title ?? null,
227
+ log: options.log === true,
228
+ valLabel: options.valLabel ?? 'ns/op',
229
+ },
230
+ data: {
231
+ categories: table.rows.map((r) => r.name),
232
+ series: table.columns.map((column, i) => ({
233
+ name: column,
234
+ values: table.rows.map((r) => r.results?.[i] ?? null),
235
+ })),
236
+ },
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Horizontal bars over `{label, ns}` timing rows (the view/charts
242
+ * benchmark shape). One series in one color: the bars are *nominal*
243
+ * categories — engines or scenarios — so their identity comes from the
244
+ * axis label, not from a hue, and the length is the whole message.
245
+ * Semantic win/loss tones are deliberately not used: on a timing chart
246
+ * "ours" is not automatically good, and docs/DESIGN.md reserves those tokens
247
+ * for genuine status.
248
+ * @param {{label: string, ns: number}[]} rows
249
+ * @param {{title?: string, log?: boolean, valLabel?: string}} [options]
250
+ * @returns {ChartPair}
251
+ */
252
+ export function timingBars(rows, options = {}) {
253
+ return {
254
+ config: {
255
+ type: 'bar',
256
+ title: options.title ?? null,
257
+ orient: 'h',
258
+ log: options.log === true,
259
+ valLabel: options.valLabel ?? 'ns/op',
260
+ },
261
+ data: {
262
+ categories: rows.map((r) => r.label),
263
+ series: [{
264
+ name: options.valLabel ?? 'ns/op',
265
+ values: rows.map((r) => (Number.isFinite(r.ns) ? r.ns : null)),
266
+ }],
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Cross-suite ratio bars for the benchmarks overview: one bar per suite
273
+ * headline, tone by which side of parity it lands on — here the tones
274
+ * ARE semantic (a ratio below 1 is a genuine loss, reported as one).
275
+ * @param {{key: string, label: string, ratio: number|null}[]} headlines
276
+ * @param {{title?: string}} [options]
277
+ * @returns {ChartPair}
278
+ */
279
+ export function headlineRatioBars(headlines, options = {}) {
280
+ const usable = headlines.filter((h) => Number.isFinite(h.ratio) && h.ratio > 0);
281
+ return {
282
+ config: {
283
+ type: 'bar',
284
+ title: options.title ?? null,
285
+ orient: 'h',
286
+ log: true,
287
+ valLabel: '× vs the fastest rival (log)',
288
+ },
289
+ data: {
290
+ categories: usable.map((h) => h.label),
291
+ series: [{
292
+ name: 'ratio',
293
+ values: usable.map((h) => h.ratio),
294
+ tones: usable.map((h) => (h.ratio >= 1 ? 'win' : 'loss')),
295
+ }],
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,19 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Mermaid interop: map a mermaid pie AST (`{title, showData,
4
+ * slices}`) onto a chart definition + data pair. Lives in charts so the
5
+ * dependency arrow stays one-way — `@jarenjs/mermaid` imports this,
6
+ * never the reverse; nothing here touches mermaid code.
7
+ */
8
+
9
+ /**
10
+ * Convert a mermaid pie AST to `compileChart`-shaped inputs.
11
+ * @param {{title?: string|null, showData?: boolean, slices: {label: string, value: number}[]}} ast
12
+ * @returns {{config: {type: 'pie', title: string|null}, data: {slices: {label: string, value: number}[]}}}
13
+ */
14
+ export function mermaidPieToChartAST(ast) {
15
+ return {
16
+ config: { type: 'pie', title: ast.title ?? null },
17
+ data: { slices: ast.slices },
18
+ };
19
+ }
@@ -0,0 +1,276 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The bar chart type: grouped or stacked, vertical or horizontal,
4
+ * linear or log value axis. Data shape:
5
+ *
6
+ * data = { categories: string[],
7
+ * series: [{ name, values: number[],
8
+ * tone?: 'win'|'loss', tones?: (('win'|'loss'|null)[]) }] }
9
+ * config = { type:'bar', title?, stacked?, log?, orient?: 'v'|'h',
10
+ * catLabel?, valLabel? }
11
+ *
12
+ * The AST is orientation-agnostic: `u` runs along the category axis,
13
+ * `v` along the value axis; the render maps them to x/y per `orient`.
14
+ * Category lookups are hoisted out of the series loop (index-driven,
15
+ * never `categories.indexOf` per cell — that is O(n²) for wide charts).
16
+ */
17
+
18
+ import { svgRoot, coord } from '@jarenjs/view/helpers';
19
+ import { clamp01 } from '@jarenjs/core/math';
20
+ import { scaleLinear, scaleLog, scaleBand } from '../core/scale.js';
21
+ import { axisTicksLinear, axisTicksLog, niceStep, formatTickValue } from '../core/axis.js';
22
+ import { cartesianFrame, toneColor, annotateChart } from '../core/cartesian.js';
23
+ import { CATEGORICAL } from '../core/palette.js';
24
+ import { normalizeTooltip, valueMark } from '../core/marks.js';
25
+
26
+ /**
27
+ * @typedef {object} BarAST
28
+ * @property {'bar'} type
29
+ * @property {string|null} title
30
+ * @property {'v'|'h'} orient
31
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} cat
32
+ * @property {{ticks: {pos:number,label:string}[], label: string|null}} val
33
+ * @property {{name: string, swatch: number}[]|null} legend
34
+ * @property {BarMarkAST[]} bars
35
+ * @property {number} count number of categories
36
+ * @property {[number, number]} domain resolved value-axis bounds
37
+ */
38
+ /**
39
+ * @typedef {object} BarMarkAST
40
+ * @property {number} u0 @property {number} u1 category-axis band
41
+ * @property {number} v0 @property {number} v1 value-axis extent
42
+ * @property {number} series series index
43
+ * @property {'win'|'loss'|null} tone
44
+ * @property {string} label the category this bar stands in
45
+ * @property {string} name the series name
46
+ * @property {number} value the drawn value (hover text reports it exactly)
47
+ */
48
+
49
+ /**
50
+ * Scan the drawn values for the extremes the value axis is resolved
51
+ * from: the largest bar (a stacked chart's per-category total) and the
52
+ * smallest positive value a log axis needs for its bottom decade.
53
+ *
54
+ * Exported because the incremental session must reach the same bounds
55
+ * decision from the same numbers — one implementation, no drift.
56
+ * @param {any} data
57
+ * @param {boolean} stacked @param {boolean} log
58
+ * @returns {{maxVal: number, minPos: number}}
59
+ */
60
+ export function scanBarExtremes(data, stacked, log) {
61
+ const categories = data?.categories ?? [];
62
+ const series = (data?.series ?? []).filter((s) => Array.isArray(s.values));
63
+ let maxVal = 0;
64
+ let minPos = Infinity;
65
+ for (let ci = 0; ci < categories.length; ci++) {
66
+ let sum = 0;
67
+ for (const s of series) {
68
+ const v = s.values[ci];
69
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue;
70
+ sum += v;
71
+ if (!stacked && v > maxVal) maxVal = v;
72
+ if (v < minPos) minPos = v;
73
+ }
74
+ if (stacked && sum > maxVal) maxVal = sum;
75
+ }
76
+ if (maxVal === 0) maxVal = 1;
77
+ if (!Number.isFinite(minPos)) minPos = log ? 0.1 : 0;
78
+ return { maxVal, minPos };
79
+ }
80
+
81
+ /**
82
+ * Resolve the value-axis bounds and tick values from the scanned
83
+ * extremes: a nice-number top over a zero base, or whole decades under
84
+ * `log`. The AST records the bounds so a later build — or the session —
85
+ * can detect "unchanged".
86
+ * @param {{maxVal: number, minPos: number}} ext
87
+ * @param {boolean} log
88
+ * @returns {{domain: [number, number], tickValues: number[]}}
89
+ */
90
+ export function resolveBarDomains(ext, log) {
91
+ if (log) {
92
+ const lo = Math.pow(10, Math.floor(Math.log10(ext.minPos)));
93
+ const raw = Math.pow(10, Math.ceil(Math.log10(ext.maxVal)));
94
+ const hi = raw === lo ? lo * 10 : raw;
95
+ return { domain: [lo, hi], tickValues: axisTicksLog(lo, hi) };
96
+ }
97
+ const step = niceStep(ext.maxVal, 5);
98
+ const top = step * Math.ceil(ext.maxVal / step);
99
+ return { domain: [0, top], tickValues: axisTicksLinear(0, top, 5) };
100
+ }
101
+
102
+ /**
103
+ * The value scale over a resolved domain — reconstructable by the
104
+ * session from the AST's domain alone.
105
+ * @param {[number, number]} domain @param {boolean} log
106
+ * @returns {(v: number) => number}
107
+ */
108
+ export function barScale(domain, log) {
109
+ return log ? scaleLog(domain[0], domain[1]) : scaleLinear(domain[0], domain[1]);
110
+ }
111
+
112
+ /**
113
+ * Build the geometry-free bar AST.
114
+ * @param {any} data
115
+ * @param {any} [config]
116
+ * @returns {BarAST}
117
+ */
118
+ export function buildBarAST(data, config = {}) {
119
+ const categories = data?.categories ?? [];
120
+ const series = (data?.series ?? []).filter((s) => Array.isArray(s.values));
121
+ const stacked = config.stacked === true;
122
+ const log = config.log === true;
123
+
124
+ const domains = resolveBarDomains(scanBarExtremes(data, stacked, log), log);
125
+ const scale = barScale(domains.domain, log);
126
+ const valTicks = domains.tickValues;
127
+
128
+ const band = scaleBand(categories, 0.25);
129
+ const groups = stacked ? 1 : Math.max(1, series.length);
130
+ const sub = band.bandwidth / groups;
131
+ const bars = [];
132
+ const running = stacked ? new Array(categories.length).fill(0) : null;
133
+ for (let si = 0; si < series.length; si++) {
134
+ const s = series[si];
135
+ for (let ci = 0; ci < categories.length; ci++) {
136
+ const v = s.values[ci];
137
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue;
138
+ const start = band(categories[ci]);
139
+ let u0;
140
+ let u1;
141
+ let v0;
142
+ let v1;
143
+ if (stacked) {
144
+ u0 = start;
145
+ u1 = start + band.bandwidth;
146
+ const base = running[ci];
147
+ running[ci] = base + v;
148
+ v0 = base === 0 ? 0 : clamp01(scale(base));
149
+ v1 = clamp01(scale(running[ci]));
150
+ }
151
+ else {
152
+ u0 = start + si * sub + sub * 0.06;
153
+ u1 = start + (si + 1) * sub - sub * 0.06;
154
+ v0 = 0;
155
+ v1 = clamp01(scale(v));
156
+ }
157
+ bars.push({
158
+ u0, u1, v0, v1,
159
+ series: si,
160
+ tone: s.tones?.[ci] ?? s.tone ?? null,
161
+ label: String(categories[ci]),
162
+ name: String(s.name ?? ''),
163
+ value: v,
164
+ });
165
+ }
166
+ }
167
+
168
+ return {
169
+ type: 'bar',
170
+ title: config.title ?? null,
171
+ orient: config.orient === 'h' ? 'h' : 'v',
172
+ cat: {
173
+ ticks: categories.map((label) => ({ pos: band(label) + band.bandwidth / 2, label: String(label) })),
174
+ label: config.catLabel ?? null,
175
+ },
176
+ val: {
177
+ ticks: valTicks.map((v) => ({ pos: clamp01(scale(v)), label: formatTickValue(v) })),
178
+ label: config.valLabel ?? null,
179
+ },
180
+ legend: series.length > 1 ? series.map((s, i) => ({ name: s.name, swatch: i })) : null,
181
+ bars,
182
+ count: categories.length,
183
+ domain: domains.domain,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Render one bar as its `<rect>` value mark — the replaceable unit the
189
+ * incremental session re-emits when a live count changes.
190
+ * @param {BarMarkAST} bar
191
+ * @param {BarAST} ast
192
+ * @param {{x:number,y:number,w:number,h:number}} plot
193
+ * @param {{tokens: Record<string,string>}} theme
194
+ * @param {readonly string[]} palette
195
+ * @param {import('../core/marks.js').ChartTooltip|null} [tooltip]
196
+ * @returns {any}
197
+ */
198
+ export function barMarkRender(bar, ast, plot, theme, palette, tooltip = null) {
199
+ let x; let y; let w; let h;
200
+ if (ast.orient === 'h') {
201
+ x = plot.x + bar.v0 * plot.w;
202
+ w = (bar.v1 - bar.v0) * plot.w;
203
+ y = plot.y + bar.u0 * plot.h;
204
+ h = (bar.u1 - bar.u0) * plot.h;
205
+ }
206
+ else {
207
+ x = plot.x + bar.u0 * plot.w;
208
+ w = (bar.u1 - bar.u0) * plot.w;
209
+ y = plot.y + (1 - bar.v1) * plot.h;
210
+ h = (bar.v1 - bar.v0) * plot.h;
211
+ }
212
+ const text = ast.legend !== null
213
+ ? `${bar.name} — ${bar.label}: ${bar.value}`
214
+ : `${bar.label}: ${bar.value}`;
215
+ return valueMark('rect', {
216
+ x: coord(x), y: coord(y), width: coord(Math.max(0.5, w)), height: coord(Math.max(0.5, h)),
217
+ fill: toneColor(theme, bar.tone, bar.series, palette), class: 'chart-bar',
218
+ }, tooltip, text, { type: 'bar', label: bar.label, series: bar.name, value: bar.value });
219
+ }
220
+
221
+ /**
222
+ * Render a bar AST and return the svg WITH the geometry a session needs
223
+ * to replace one bar in place: the plot rect and how many chrome
224
+ * children precede the bars.
225
+ * @param {BarAST} ast
226
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
227
+ * @param {string} hash
228
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
229
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
230
+ * @returns {{svg: any, plot: {x:number,y:number,w:number,h:number}, chromeLen: number}}
231
+ */
232
+ export function buildBarRender(ast, theme, hash, options = {}) {
233
+ const palette = options.palette ?? CATEGORICAL;
234
+ const tooltip = normalizeTooltip(options.tooltip);
235
+ const horizontal = ast.orient === 'h';
236
+ const frame = cartesianFrame({
237
+ title: ast.title,
238
+ legend: ast.legend,
239
+ xAxis: horizontal ? ast.val : { ...ast.cat, ticks: ast.cat.ticks },
240
+ yAxis: horizontal ? { ...ast.cat, ticks: ast.cat.ticks.map(flipPos) } : ast.val,
241
+ grid: horizontal ? 'x' : 'y',
242
+ width: options.width,
243
+ plotHeight: horizontal ? Math.max(80, ast.count * 28) : undefined,
244
+ palette,
245
+ theme,
246
+ });
247
+ const { plot } = frame;
248
+ const children = frame.children;
249
+ const chromeLen = children.length;
250
+ for (const bar of ast.bars)
251
+ children.push(barMarkRender(bar, ast, plot, theme, palette, tooltip));
252
+ const svg = svgRoot(options.rootClass ?? 'chart chart-svg chart-bar-chart',
253
+ frame.width, frame.height, theme, children, (options.keyPrefix ?? 'bar-') + hash);
254
+ annotateChart(svg, ast.title);
255
+ return { svg, plot, chromeLen };
256
+ }
257
+
258
+ /**
259
+ * Render a bar AST to a pure-vnode SVG. Each bar rect carries a
260
+ * `<title>` naming its series, category and exact value — the axis
261
+ * shows the rounded tick scale, the hover text shows the datum.
262
+ * @param {BarAST} ast
263
+ * @param {{tokens: Record<string,string>, cssVars: Record<string,string>}} theme
264
+ * @param {string} hash
265
+ * @param {{rootClass?: string, keyPrefix?: string, palette?: readonly string[], width?: number,
266
+ * tooltip?: import('../core/marks.js').ChartTooltipSpec}} [options]
267
+ * @returns {any}
268
+ */
269
+ export function renderBarAST(ast, theme, hash, options = {}) {
270
+ return buildBarRender(ast, theme, hash, options).svg;
271
+ }
272
+
273
+ /** In horizontal orientation the first category reads at the top. */
274
+ function flipPos(tick) {
275
+ return { pos: 1 - tick.pos, label: tick.label };
276
+ }