@dta-au/civictheme-twig 1.13.1 → 1.13.2

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,2725 @@
1
+ /**
2
+ * CivicTheme Chart component.
3
+ */
4
+
5
+ // Storybook / static-page bootstrap. In Drupal, Drupal.behaviors + once are
6
+ // real globals and Drupal.attachBehaviors() runs the registered behaviour
7
+ // after each AJAX swap. Outside Drupal (this UIKit's Storybook, static demo
8
+ // pages), neither exists, so the file would throw at load. Provide minimal
9
+ // shims, then run behaviours on DOMContentLoaded and on each DOM mutation so
10
+ // stories that mount their chart markup async still pick it up. Drupal pages
11
+ // already have these globals — the typeof guards keep the shims inert there.
12
+ (function () {
13
+ 'use strict';
14
+ if (typeof window.Drupal === 'undefined') {
15
+ // Drupal.t in core substitutes @placeholder / !placeholder / %placeholder
16
+ // tokens from the second arg. The renderer uses @count and @nodes — without
17
+ // substitution the live-status string renders as
18
+ // "Chart loaded. @count rows."
19
+ // Reproduce just enough of core's contract so the status reads naturally
20
+ // in Storybook.
21
+ window.Drupal = {
22
+ behaviors: {},
23
+ t: (str, args) => {
24
+ if (!args) return str;
25
+ return String(str).replace(/[@!%][\w-]+/g, (m) => (m in args ? String(args[m]) : m));
26
+ },
27
+ };
28
+ }
29
+ if (typeof window.once === 'undefined') {
30
+ const marks = new WeakMap();
31
+ window.once = function (id, selector, context) {
32
+ const root = context || document;
33
+ const out = [];
34
+ root.querySelectorAll(selector).forEach((el) => {
35
+ const keys = marks.get(el) || new Set();
36
+ if (keys.has(id)) return;
37
+ keys.add(id);
38
+ marks.set(el, keys);
39
+ out.push(el);
40
+ });
41
+ return out;
42
+ };
43
+ }
44
+ })();
45
+
46
+ (function (Drupal, once) {
47
+ 'use strict';
48
+
49
+ const ALLOWED_HOSTS = ['data.gov.au', 'www.data.gov.au'];
50
+ const MAX_ROWS = 5000;
51
+ const FETCH_TIMEOUT_MS = 10000;
52
+ // Per-cell string cap for extracted CKAN rows. Protects against shipping
53
+ // multi-KB descriptive prose columns when an author runs a SELECT * style
54
+ // query; row count is already clamped by MAX_ROWS. 500 chars covers every
55
+ // realistic chart label.
56
+ const MAX_CELL_CHARS = 500;
57
+
58
+ // Ordinal rank table for sankey node labels. Used to:
59
+ // (a) sort group colour assignment so e.g. "High" always gets the
60
+ // darkest sequential shade regardless of where it appears in the
61
+ // data, and
62
+ // (b) drive d3-sankey's nodeSort so the same vocabulary stacks
63
+ // top-to-bottom by rank in every column.
64
+ // Lower number = higher priority (= darker colour, = higher in column).
65
+ // Labels not present here fall back to encounter order.
66
+ //
67
+ // Currently covers MDPR Delivery Confidence Assessment ratings. Extend
68
+ // here when other ordinal vocabularies appear (project lifecycle states,
69
+ // likert scales, RAG statuses, etc.) - the entries are matched case-
70
+ // insensitively after trimming and collapsing internal whitespace.
71
+ const ORDINAL_RANK = {
72
+ high: 0,
73
+ 'medium-high': 1,
74
+ medium: 2,
75
+ 'medium-low': 3,
76
+ low: 4,
77
+ 'not reported': 5,
78
+ 'not-reported': 5,
79
+ 'unable to rate': 6,
80
+ };
81
+
82
+ function rankOf(label) {
83
+ if (!label) return null;
84
+ const k = String(label).toLowerCase().trim().replace(/\s+/g, ' ');
85
+ return Object.prototype.hasOwnProperty.call(ORDINAL_RANK, k) ? ORDINAL_RANK[k] : null;
86
+ }
87
+
88
+ /**
89
+ * Sort comparator for groups identified by their label string. Ranked
90
+ * labels sort by rank ascending; unranked labels keep encounter order
91
+ * (stable sort, returning 0). Mixed pairs put ranked labels first so
92
+ * the colour ramp starts on the known ordinal.
93
+ */
94
+ function compareByRank(a, b) {
95
+ const ra = rankOf(a);
96
+ const rb = rankOf(b);
97
+ if (ra !== null && rb !== null) return ra - rb;
98
+ if (ra !== null) return -1;
99
+ if (rb !== null) return 1;
100
+ return 0;
101
+ }
102
+
103
+ // IBM Carbon Charts 14-series Categorical palette (light theme defaults).
104
+ // Sourced from packages/core/scss/_color-palette.scss in
105
+ // carbon-design-system/carbon-charts. Dark-theme equivalents are declared
106
+ // in chart.css under
107
+ // .ct-theme-dark .bdga-chart and override these defaults via CSS custom
108
+ // properties at render time.
109
+ //
110
+ // The CSS-variable hook (--bdga-chart-c1..c14, --bdga-chart-s1..s6) lets a
111
+ // sub-theme swap palettes without touching this file.
112
+ const PALETTE_DEFAULT = [
113
+ '#6929c4', // purple 70 - series 1 (default for single-series charts)
114
+ '#1192e8', // cyan 50
115
+ '#005d5d', // teal 70
116
+ '#9f1853', // magenta 70
117
+ '#fa4d56', // red 50
118
+ '#520408', // red 90
119
+ '#198038', // green 60
120
+ '#002d9c', // blue 80
121
+ '#ee5396', // magenta 50
122
+ '#b28600', // yellow 50
123
+ '#009d9a', // teal 50
124
+ '#012749', // cyan 90
125
+ '#8a3800', // orange 70
126
+ '#a56eff', // purple 50
127
+ ];
128
+ // Sequential ramp for charts with >14 series: progressively lighter purples
129
+ // anchored on series-1 (purple 70). Dark theme overrides via CSS.
130
+ const SEQUENTIAL_DEFAULT = ['#6929c4', '#8a3ffc', '#a56eff', '#be95ff', '#d4bbff', '#e8daff'];
131
+
132
+ /**
133
+ * Resolve a CSS custom property against a DOM element, with fallback.
134
+ * Empty / undefined values fall back to the default so authors can leave
135
+ * gaps in their override (e.g. only override c1 and c2).
136
+ */
137
+ function cssVar(el, name, fallback) {
138
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
139
+ return v !== '' ? v : fallback;
140
+ }
141
+
142
+ /**
143
+ * Numeric variant of cssVar: read a length-like custom property and parse
144
+ * its leading number (px). Used for the responsive layout knobs that the
145
+ * @container queries in chart.scss switch by container width. Returns the
146
+ * fallback when the property is unset or non-numeric.
147
+ */
148
+ function cssNum(el, name, fallback) {
149
+ const n = parseFloat(getComputedStyle(el).getPropertyValue(name));
150
+ return Number.isFinite(n) ? n : fallback;
151
+ }
152
+
153
+ /**
154
+ * Read the 14 categorical and 6 sequential colour stops from CSS on the
155
+ * given chart element. Computed once per chart at draw time.
156
+ */
157
+ function resolvePalette(el) {
158
+ const categorical = PALETTE_DEFAULT.map((d, i) => cssVar(el, `--bdga-chart-c${ i + 1}`, d));
159
+ const sequential = SEQUENTIAL_DEFAULT.map((d, i) => cssVar(el, `--bdga-chart-s${ i + 1}`, d));
160
+ return { categorical, sequential, single: categorical[0] };
161
+ }
162
+
163
+ function shadeSequential(palette, index, total) {
164
+ if (total <= 1) return palette.single;
165
+ return palette.sequential[Math.min(index, palette.sequential.length - 1)];
166
+ }
167
+
168
+ Drupal.behaviors.bdgaChart = {
169
+ attach(context) {
170
+ if (typeof window.d3 === 'undefined') {
171
+ // D3 vendored library not loaded; keep the table fallback.
172
+ return;
173
+ }
174
+ once('bdga-chart', '[data-bdga-chart]', context).forEach((el) => {
175
+ // eslint-disable-next-line no-use-before-define
176
+ new BdgaChart(el).init();
177
+ });
178
+ },
179
+ };
180
+
181
+ class BdgaChart {
182
+ constructor(root) {
183
+ this.root = root;
184
+ this.canvas = root.querySelector('[data-bdga-chart-canvas]');
185
+ this.errorEl = root.querySelector('[data-bdga-chart-error]');
186
+ this.tableEl = root.querySelector('[data-bdga-chart-data]');
187
+ this.statusEl = root.querySelector('[data-bdga-chart-status]');
188
+ this.configEl = root.querySelector('script[type="application/json"][data-bdga-chart-config]');
189
+
190
+ // Read primary config from the JSON data island. Fall back to data-*
191
+ // attributes + table walk only if the island isn't present (e.g. an
192
+ // older cached render of the markup, or a hand-rolled embed).
193
+ const config = this.readConfig();
194
+ if (config) {
195
+ this.id = config.id || root.id || null;
196
+ this.type = config.type || root.dataset.bdgaChart || 'bar';
197
+ this.mode = config.source || 'json';
198
+ this.url = config.url || null;
199
+ this.xKey = config.x_key || null;
200
+ this.yKeys = Array.isArray(config.y_keys) ? config.y_keys.slice() : [];
201
+ this.rows = Array.isArray(config.rows) ? config.rows : [];
202
+ this.locale = config.locale || null;
203
+ this.maxRows = config.max_rows || MAX_ROWS;
204
+ this.xLabel = config.x_label || this.xKey;
205
+ this.yLabel = config.y_label || (this.yKeys.length === 1 ? this.yKeys[0] : '');
206
+ this.colorBy = config.color_by === 'category' ? 'category' : 'series';
207
+ // Sankey / flow shape - parallel to rows. drawSankey / drawFlow
208
+ // ignore rows entirely and read these instead.
209
+ this.nodes = Array.isArray(config.nodes) ? config.nodes : null;
210
+ this.links = Array.isArray(config.links) ? config.links : null;
211
+ // Lollipop median reference line; null disables.
212
+ this.medianValue = (typeof config.median_value === 'number' && Number.isFinite(config.median_value))
213
+ ? config.median_value
214
+ : null;
215
+ }
216
+ else {
217
+ this.id = root.dataset.bdgaChartId;
218
+ this.type = root.dataset.bdgaChart;
219
+ this.mode = root.dataset.bdgaChartSource;
220
+ this.url = root.dataset.bdgaChartUrl || null;
221
+ this.xKey = root.dataset.bdgaChartX || null;
222
+ this.yKeys = (root.dataset.bdgaChartY || '').split(',').filter(Boolean);
223
+ this.rows = null;
224
+ this.locale = null;
225
+ this.maxRows = MAX_ROWS;
226
+ this.xLabel = this.xKey;
227
+ this.yLabel = this.yKeys[0] || '';
228
+ this.colorBy = 'series';
229
+ this.nodes = null;
230
+ this.links = null;
231
+ this.medianValue = null;
232
+ }
233
+
234
+ // Toolbar (optional, Phase 1). References resolve to null when the
235
+ // `toolbar` prop didn't render the markup, in which case initToolbar()
236
+ // is a no-op. downloads is read from the figure's data-* attribute so
237
+ // it works in Storybook and Drupal alike, independent of config_json.
238
+ this.toolbarEl = root.querySelector('[data-bdga-chart-toolbar]');
239
+ this.menuEl = root.querySelector('[data-bdga-chart-menu]');
240
+ this.menuButtonEl = root.querySelector('[data-bdga-chart-menu-button]');
241
+ this.tableToggleEl = root.querySelector('[data-bdga-chart-tool="table"]');
242
+ this.detailsEl = this.tableEl ? this.tableEl.closest('details') : null;
243
+ this.downloads = (root.dataset.bdgaChartDownloads || '')
244
+ .split(',')
245
+ .map((s) => s.trim())
246
+ .filter((s) => s === 'csv' || s === 'json');
247
+ this.menuOpen = false;
248
+ this.menuItems = [];
249
+
250
+ // Legend (optional, Phase 2). Series toggled here are tracked in
251
+ // `hidden` (by y-key, or by x-value for pie) and excluded at draw time.
252
+ this.legendEl = root.querySelector('[data-bdga-chart-legend]');
253
+ this.legendBuilt = false;
254
+ this.legendButtons = new Map();
255
+ this.hidden = new Set();
256
+
257
+ // Texture fills (optional, Phase 4): SVG pattern fills layered on the
258
+ // series colour as a colour-blind-safe redundant cue.
259
+ this.texture = root.dataset.bdgaChartTexture === 'true';
260
+
261
+ // Zoom (optional, Phase 4): data-domain windowing for the ordinal
262
+ // cartesian types. zoomWindow is an inclusive {start, end} index range
263
+ // into the full ordered data; null = full extent.
264
+ this.zoomGroupEl = root.querySelector('[data-bdga-chart-zoom-group]');
265
+ this.zoom = !!this.zoomGroupEl;
266
+ this.zoomWindow = null;
267
+ }
268
+
269
+ /**
270
+ * Parse the JSON data island. Returns null if absent or unparseable.
271
+ * Failures here are silent at the constructor level; init() decides
272
+ * whether to fall back to the table or fail loudly.
273
+ */
274
+ readConfig() {
275
+ if (!this.configEl) return null;
276
+ try {
277
+ const txt = this.configEl.textContent || '';
278
+ if (!txt.trim()) return null;
279
+ return JSON.parse(txt);
280
+ }
281
+ catch (e) {
282
+ if (window.console) {
283
+ window.console.warn('[bdga-chart] config JSON parse failed:', e);
284
+ }
285
+ return null;
286
+ }
287
+ }
288
+
289
+ init() {
290
+ this.observeResize();
291
+ this.initToolbar();
292
+ try {
293
+ if (this.mode === 'url') {
294
+ return this.loadFromUrl();
295
+ }
296
+ // Sankey / flow read nodes + links from the JSON island; the table
297
+ // is the AT fallback only, not a data source for the renderer.
298
+ if (this.type === 'sankey' || this.type === 'flow') {
299
+ if (!this.nodes || !this.nodes.length || !this.links || !this.links.length) {
300
+ return this.fail('Sankey/flow chart requires nodes and links');
301
+ }
302
+ if (typeof window.d3 === 'undefined' || typeof window.d3.sankey !== 'function') {
303
+ return this.fail('d3-sankey plugin missing');
304
+ }
305
+ return this.draw([]);
306
+ }
307
+ // Prefer rows from the JSON island; only walk the <table> if we
308
+ // didn't get any (older markup, or a hand-rolled embed).
309
+ let rows = this.rows;
310
+ if (!rows || !rows.length) {
311
+ rows = this.readTable();
312
+ }
313
+ if (!rows.length) return this.fail('No rows in data island or fallback table');
314
+ this.draw(rows);
315
+ } catch (err) {
316
+ this.fail(err && err.message ? err.message : String(err));
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Re-lay-out the chart when its container width changes (device rotation,
322
+ * responsive sidebar, Storybook viewport switch). The SVG already CSS-
323
+ * scales via its viewBox, but re-running the draw at the new pixel width
324
+ * keeps tick text, stroke widths and the sankey @container margin knobs
325
+ * crisp and correct. No-op until the first successful draw has stored its
326
+ * inputs, and a no-op on browsers without ResizeObserver.
327
+ */
328
+ observeResize() {
329
+ if (typeof ResizeObserver === 'undefined' || this.resizeObserver || !this.canvas) {
330
+ return;
331
+ }
332
+ let timer = 0;
333
+ this.resizeObserver = new ResizeObserver(() => {
334
+ // Debounce a burst of resize callbacks (e.g. during a drag) into one
335
+ // redraw once the width settles. setTimeout (not rAF) so the redraw
336
+ // still lands when the chart is in a backgrounded / hidden tab, where
337
+ // rAF is paused.
338
+ window.clearTimeout(timer);
339
+ timer = window.setTimeout(() => {
340
+ if (this.lastDrawData === undefined) return;
341
+ const w = this.canvas.clientWidth || 0;
342
+ // Ignore sub-pixel jitter and the observer's own initial callback
343
+ // (same width as the last draw) so a redraw can't feed itself.
344
+ if (Math.abs(w - (this.lastDrawWidth || 0)) <= 2) return;
345
+ this.redraw();
346
+ }, 150);
347
+ });
348
+ this.resizeObserver.observe(this.canvas);
349
+ }
350
+
351
+ redraw() {
352
+ try {
353
+ this.draw(this.lastDrawData || []);
354
+ } catch {
355
+ // Keep the last good render rather than blanking the canvas on a
356
+ // transient resize-time error.
357
+ }
358
+ }
359
+
360
+ setStatus(msg) {
361
+ if (this.statusEl) this.statusEl.textContent = msg;
362
+ }
363
+
364
+ fail(reason) {
365
+ if (window.console) {
366
+ window.console.warn(`[bdga-chart] ${ this.id }: ${ reason}`);
367
+ }
368
+ if (this.errorEl) this.errorEl.hidden = false;
369
+ if (this.canvas) this.canvas.setAttribute('aria-hidden', 'true');
370
+ this.setStatus(Drupal.t('Chart unavailable.'));
371
+ }
372
+
373
+ // -- Toolbar (Phase 1) ---------------------------------------------------
374
+ //
375
+ // Accessible controls layered on top of the table-first markup. The
376
+ // "View as table" button drives the existing data disclosure; the overflow
377
+ // menu offers a source-aware action set (download links for local data, a
378
+ // "view source" link in url mode) using the WAI-ARIA menu-button keyboard
379
+ // model. The menu items are built here, not server-side, because their
380
+ // payload derives from the live data the renderer holds; no-JS users keep
381
+ // the table (its own <summary> still works) and the url-mode <noscript>.
382
+
383
+ initToolbar() {
384
+ if (!this.toolbarEl) return;
385
+ this.wireTableToggle();
386
+ this.buildMenu();
387
+ this.wireMenu();
388
+ this.wireZoom();
389
+ }
390
+
391
+ wireZoom() {
392
+ if (!this.zoomGroupEl) return;
393
+ this.zoomGroupEl.addEventListener('click', (e) => {
394
+ const btn = e.target.closest('[data-bdga-chart-zoom]');
395
+ if (!btn) return;
396
+ const action = btn.getAttribute('data-bdga-chart-zoom');
397
+ // Centre on the last-focused data point when there is one (clicking the
398
+ // button moved focus off it, but focusPos still records where it was);
399
+ // otherwise centre on the current window.
400
+ const center = this.pointFocused ? this.currentFullIndex() : null;
401
+ if (action === 'in') this.zoomIn(center);
402
+ else if (action === 'out') this.zoomOut(center);
403
+ else this.zoomReset();
404
+ });
405
+ }
406
+
407
+ applicableZoom() {
408
+ return this.zoom && (this.type === 'bar' || this.type === 'line' || this.type === 'lollipop');
409
+ }
410
+
411
+ sliceZoom(rows) {
412
+ if (!this.zoomWindow) return rows;
413
+ return rows.slice(this.zoomWindow.start, this.zoomWindow.end + 1);
414
+ }
415
+
416
+ /** Full-data index of the focused point, for +/- zoom. */
417
+ currentFullIndex() {
418
+ const base = this.zoomWindow ? this.zoomWindow.start : 0;
419
+ return base + (this.focusPos ? this.focusPos.i : 0);
420
+ }
421
+
422
+ /**
423
+ * Re-window around a centre index. factor < 1 zooms in, > 1 zooms out.
424
+ * Clamps to >= 2 points and to the data bounds; a window covering the whole
425
+ * range resets to null (full extent). When refocus is set (key-driven
426
+ * zoom), keyboard focus is restored to the SAME data point it centred on -
427
+ * not the first point - so the user keeps their place across the redraw.
428
+ */
429
+ zoomBy(factor, centerIdx, refocus) {
430
+ const rows = this.lastDrawData || [];
431
+ const n = rows.length;
432
+ if (n < 3) return;
433
+ // Capture the focused point's identity (series group + full-data index)
434
+ // before the redraw rebuilds the point model.
435
+ const g = this.focusPos ? this.focusPos.g : 0;
436
+ const win = this.zoomWindow || { start: 0, end: n - 1 };
437
+ const span = win.end - win.start + 1;
438
+ const newSpan = Math.max(2, Math.round(span * factor));
439
+ if (newSpan >= n) {
440
+ this.zoomWindow = null;
441
+ }
442
+ else {
443
+ const center = centerIdx != null ? centerIdx : Math.floor((win.start + win.end) / 2);
444
+ let start = Math.round(center - newSpan / 2);
445
+ start = Math.max(0, Math.min(start, n - newSpan));
446
+ this.zoomWindow = { start, end: start + newSpan - 1 };
447
+ }
448
+ this.redraw();
449
+ this.announceZoom();
450
+ if (refocus) this.refocusPoint(g, centerIdx);
451
+ }
452
+
453
+ zoomIn(centerIdx, refocus) {
454
+ this.zoomBy(0.6, centerIdx, refocus);
455
+ }
456
+
457
+ zoomOut(centerIdx, refocus) {
458
+ this.zoomBy(1.8, centerIdx, refocus);
459
+ }
460
+
461
+ zoomReset(centerIdx, refocus) {
462
+ const g = this.focusPos ? this.focusPos.g : 0;
463
+ this.zoomWindow = null;
464
+ this.redraw();
465
+ this.announceZoom();
466
+ if (refocus) this.refocusPoint(g, centerIdx);
467
+ }
468
+
469
+ /**
470
+ * Restore keyboard focus to the data point with the given full-data index
471
+ * within series group g, after a zoom rebuilt the point model. The centred
472
+ * point is always still in the window, so this keeps the user on it;
473
+ * clamps if the group is shorter than expected.
474
+ */
475
+ refocusPoint(g, fullIdx) {
476
+ if (!this.pointGroups.length) return;
477
+ const gi = Math.min(g, this.pointGroups.length - 1);
478
+ const group = this.pointGroups[gi];
479
+ if (!group || !group.length) return;
480
+ const base = this.zoomWindow ? this.zoomWindow.start : 0;
481
+ let i = fullIdx == null ? 0 : fullIdx - base;
482
+ i = Math.max(0, Math.min(i, group.length - 1));
483
+ this.focusPoint(gi, i);
484
+ }
485
+
486
+ announceZoom() {
487
+ const rows = this.lastDrawData || [];
488
+ if (!this.zoomWindow) {
489
+ this.setStatus(Drupal.t('Showing all @n points.', { '@n': rows.length }));
490
+ return;
491
+ }
492
+ const { start, end } = this.zoomWindow;
493
+ this.setStatus(
494
+ Drupal.t('Showing @a to @b, @c of @n points.', {
495
+ '@a': rows[start] ? rows[start][this.xKey] : '',
496
+ '@b': rows[end] ? rows[end][this.xKey] : '',
497
+ '@c': end - start + 1,
498
+ '@n': rows.length,
499
+ })
500
+ );
501
+ }
502
+
503
+ wireTableToggle() {
504
+ const btn = this.tableToggleEl;
505
+ const details = this.detailsEl;
506
+ if (!btn || !details) return;
507
+ btn.addEventListener('click', () => {
508
+ details.open = !details.open;
509
+ if (details.open) {
510
+ const summary = details.querySelector('summary');
511
+ if (summary) summary.focus();
512
+ this.setStatus(Drupal.t('Showing data table.'));
513
+ }
514
+ });
515
+ // Mirror the button's state when the disclosure is toggled directly via
516
+ // its native summary, so the two controls never disagree.
517
+ const sync = () => btn.setAttribute('aria-expanded', String(details.open));
518
+ details.addEventListener('toggle', sync);
519
+ sync();
520
+ }
521
+
522
+ /**
523
+ * Build the overflow menu's items. url mode offers a single "view source"
524
+ * link; local modes offer a download button per configured format. When
525
+ * there is nothing to offer, the menu button is removed so we never ship
526
+ * an empty menu.
527
+ */
528
+ buildMenu() {
529
+ const menu = this.menuEl;
530
+ if (!menu) return;
531
+ const items = [];
532
+
533
+ if (this.mode === 'url' && this.url) {
534
+ const a = document.createElement('a');
535
+ a.className = 'bdga-chart__menu-item';
536
+ a.setAttribute('role', 'menuitem');
537
+ a.href = this.url;
538
+ a.target = '_blank';
539
+ a.rel = 'noopener nofollow';
540
+ a.textContent = Drupal.t('View source data (opens in new tab)');
541
+ a.addEventListener('click', () => this.closeMenu(false));
542
+ items.push(a);
543
+ }
544
+ else {
545
+ this.downloads.forEach((fmt) => {
546
+ const b = document.createElement('button');
547
+ b.type = 'button';
548
+ b.className = 'bdga-chart__menu-item';
549
+ b.setAttribute('role', 'menuitem');
550
+ b.textContent = fmt === 'json'
551
+ ? Drupal.t('Download data (JSON)')
552
+ : Drupal.t('Download data (CSV)');
553
+ b.addEventListener('click', () => {
554
+ this.download(fmt);
555
+ this.closeMenu(true);
556
+ });
557
+ items.push(b);
558
+ });
559
+ }
560
+
561
+ if (!items.length) {
562
+ const wrap = this.menuButtonEl && this.menuButtonEl.closest('.bdga-chart__menu-wrap');
563
+ if (wrap) wrap.remove();
564
+ this.menuButtonEl = null;
565
+ this.menuEl = null;
566
+ return;
567
+ }
568
+
569
+ menu.replaceChildren(
570
+ ...items.map((el) => {
571
+ const li = document.createElement('li');
572
+ li.setAttribute('role', 'none');
573
+ li.appendChild(el);
574
+ return li;
575
+ })
576
+ );
577
+ this.menuItems = items;
578
+ items.forEach((el, i) => el.setAttribute('tabindex', i === 0 ? '0' : '-1'));
579
+ }
580
+
581
+ wireMenu() {
582
+ const button = this.menuButtonEl;
583
+ const menu = this.menuEl;
584
+ if (!button || !menu || !this.menuItems.length) return;
585
+
586
+ button.addEventListener('click', () => {
587
+ if (this.menuOpen) this.closeMenu(true);
588
+ else this.openMenu(0);
589
+ });
590
+ button.addEventListener('keydown', (e) => {
591
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
592
+ e.preventDefault();
593
+ this.openMenu(0);
594
+ }
595
+ else if (e.key === 'ArrowUp') {
596
+ e.preventDefault();
597
+ this.openMenu(this.menuItems.length - 1);
598
+ }
599
+ });
600
+
601
+ menu.addEventListener('keydown', (e) => {
602
+ const items = this.menuItems;
603
+ const current = items.indexOf(document.activeElement);
604
+ switch (e.key) {
605
+ case 'ArrowDown':
606
+ e.preventDefault();
607
+ this.focusMenuItem((current + 1) % items.length);
608
+ break;
609
+ case 'ArrowUp':
610
+ e.preventDefault();
611
+ this.focusMenuItem((current - 1 + items.length) % items.length);
612
+ break;
613
+ case 'Home':
614
+ e.preventDefault();
615
+ this.focusMenuItem(0);
616
+ break;
617
+ case 'End':
618
+ e.preventDefault();
619
+ this.focusMenuItem(items.length - 1);
620
+ break;
621
+ case 'Escape':
622
+ e.preventDefault();
623
+ this.closeMenu(true);
624
+ break;
625
+ case 'Tab':
626
+ // Let focus leave naturally, but collapse the menu behind it.
627
+ this.closeMenu(false);
628
+ break;
629
+ default:
630
+ break;
631
+ }
632
+ });
633
+
634
+ // Dismiss on any pointer interaction outside the toolbar while open.
635
+ document.addEventListener('pointerdown', (e) => {
636
+ if (!this.menuOpen) return;
637
+ if (this.toolbarEl && this.toolbarEl.contains(e.target)) return;
638
+ this.closeMenu(false);
639
+ });
640
+ }
641
+
642
+ openMenu(index) {
643
+ if (!this.menuEl || !this.menuButtonEl) return;
644
+ this.menuEl.hidden = false;
645
+ this.menuButtonEl.setAttribute('aria-expanded', 'true');
646
+ this.menuOpen = true;
647
+ this.focusMenuItem(index);
648
+ }
649
+
650
+ closeMenu(returnFocus) {
651
+ if (!this.menuEl || !this.menuButtonEl) return;
652
+ this.menuEl.hidden = true;
653
+ this.menuButtonEl.setAttribute('aria-expanded', 'false');
654
+ this.menuOpen = false;
655
+ if (returnFocus) this.menuButtonEl.focus();
656
+ }
657
+
658
+ focusMenuItem(index) {
659
+ const items = this.menuItems;
660
+ items.forEach((el, i) => el.setAttribute('tabindex', i === index ? '0' : '-1'));
661
+ if (items[index]) items[index].focus();
662
+ }
663
+
664
+ /**
665
+ * Trigger a client-side download of the current data in the given format.
666
+ * Reads the live data at click time so url-mode charts export whatever has
667
+ * loaded; announces the outcome through the status live region.
668
+ */
669
+ download(fmt) {
670
+ const rows = this.exportRows();
671
+ if (!rows.length) {
672
+ this.setStatus(Drupal.t('No data available to download yet.'));
673
+ return;
674
+ }
675
+ const base = String(this.id || 'chart').replace(/[^\w-]+/g, '-');
676
+ if (fmt === 'json') {
677
+ this.downloadBlob(`${base}.json`, 'application/json', JSON.stringify(rows, null, 2));
678
+ this.setStatus(Drupal.t('Data downloaded as JSON.'));
679
+ }
680
+ else {
681
+ this.downloadBlob(`${base}.csv`, 'text/csv;charset=utf-8', this.toCsv(rows));
682
+ this.setStatus(Drupal.t('Data downloaded as CSV.'));
683
+ }
684
+ }
685
+
686
+ /**
687
+ * The rows to export. Flow / sankey carry their data as links
688
+ * (source / target / value); every other type exports the plotted rows.
689
+ * Falls back to the parsed table when no draw has happened yet.
690
+ */
691
+ exportRows() {
692
+ if ((this.type === 'sankey' || this.type === 'flow') && Array.isArray(this.links)) {
693
+ return this.links.map((l) => ({
694
+ source: l.source && l.source.id ? l.source.id : l.source,
695
+ target: l.target && l.target.id ? l.target.id : l.target,
696
+ value: l.value,
697
+ }));
698
+ }
699
+ if (Array.isArray(this.lastDrawData) && this.lastDrawData.length) {
700
+ return this.lastDrawData;
701
+ }
702
+ if (Array.isArray(this.rows) && this.rows.length) return this.rows;
703
+ return this.readTable();
704
+ }
705
+
706
+ /**
707
+ * Columns to emit, in a stable order: X key then the Y series for normal
708
+ * charts, the fixed triplet for flow charts, or the first row's own keys
709
+ * as a last resort.
710
+ */
711
+ csvColumns(rows) {
712
+ if (this.type === 'sankey' || this.type === 'flow') {
713
+ return ['source', 'target', 'value'];
714
+ }
715
+ const cols = [];
716
+ if (this.xKey) cols.push(this.xKey);
717
+ (this.yKeys || []).forEach((k) => {
718
+ if (cols.indexOf(k) === -1) cols.push(k);
719
+ });
720
+ if (!cols.length && rows[0]) return Object.keys(rows[0]);
721
+ return cols;
722
+ }
723
+
724
+ toCsv(rows) {
725
+ const keys = this.csvColumns(rows);
726
+ const esc = (v) => {
727
+ const s = v === null || v === undefined ? '' : String(v);
728
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
729
+ };
730
+ const head = keys.map(esc).join(',');
731
+ const body = rows.map((r) => keys.map((k) => esc(r[k])).join(',')).join('\r\n');
732
+ return `${head}\r\n${body}\r\n`;
733
+ }
734
+
735
+ downloadBlob(filename, mime, text) {
736
+ const blob = new Blob([text], { type: mime });
737
+ const url = URL.createObjectURL(blob);
738
+ const a = document.createElement('a');
739
+ a.href = url;
740
+ a.download = filename;
741
+ document.body.appendChild(a);
742
+ a.click();
743
+ a.remove();
744
+ // Revoke on the next tick, once the download navigation has started.
745
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
746
+ }
747
+
748
+ // -- Legend + series toggle (Phase 2) ------------------------------------
749
+ //
750
+ // An interactive legend for the genuinely multi-series renderers
751
+ // (grouped_bar, stacked_bar, pie). Each item is an aria-pressed toggle
752
+ // button: pressed = shown. Hover or focus highlights that series (others
753
+ // drop to 30% opacity, per Carbon); click hides/shows it and redraws.
754
+ // Hidden state carries a non-colour cue (strike-through + "(hidden)" in the
755
+ // accessible name) so it never relies on colour alone (WCAG 1.4.1). The
756
+ // legend is an enhancement: the chart still renders, and the data table
757
+ // still carries every series, when it is absent.
758
+
759
+ /** Y-keys not currently hidden. Renderers iterate this, not this.yKeys. */
760
+ visibleKeys() {
761
+ return this.yKeys.filter((k) => !this.hidden.has(k));
762
+ }
763
+
764
+ /** Count of series still visible, across the keyed and pie shapes. */
765
+ visibleSeriesCount() {
766
+ if (this.type === 'pie') {
767
+ return (this.lastDrawData || []).filter(
768
+ (r) => !this.hidden.has(String(r[this.xKey]))
769
+ ).length;
770
+ }
771
+ return this.visibleKeys().length;
772
+ }
773
+
774
+ /**
775
+ * Stable colour for a series at its ORIGINAL index, so a series keeps its
776
+ * colour when others are toggled off. Mirrors the categorical / sequential
777
+ * policy used by the renderers.
778
+ */
779
+ seriesColor(index, total) {
780
+ const p = this.palette || resolvePalette(this.root);
781
+ return total <= p.categorical.length
782
+ ? p.categorical[index]
783
+ : shadeSequential(p, index, total);
784
+ }
785
+
786
+ /**
787
+ * Fill value for a series mark: the flat colour, or - when texture is on -
788
+ * an SVG pattern that layers a motif over that colour. The pattern keeps
789
+ * the series colour as its background, so colour and texture agree and the
790
+ * texture is a redundant cue, not a replacement (WCAG 1.4.1).
791
+ */
792
+ fillFor(svg, index, color) {
793
+ return this.texture ? this.ensurePattern(svg, index, color) : color;
794
+ }
795
+
796
+ /**
797
+ * Lazily define one pattern per series index in the svg's <defs>, cycling
798
+ * through five motifs (two hatches, dots, cross-hatch, vertical). Returns
799
+ * the url() reference. Motifs are deliberately subtle white-on-colour so
800
+ * they read as texture without the clutter Carbon and UK Gov warn about.
801
+ */
802
+ ensurePattern(svg, index, color) {
803
+ const id = `bdga-pat-${this.id || 'chart'}-${index}`.replace(/[^\w-]+/g, '-');
804
+ let defs = svg.select('defs');
805
+ if (defs.empty()) defs = svg.append('defs');
806
+ if (this.patternIds.has(id)) return `url(#${id})`;
807
+ this.patternIds.add(id);
808
+
809
+ const motif = index % 5;
810
+ const stroke = 'rgba(255, 255, 255, 0.7)';
811
+ const sw = 1.3;
812
+ const p = defs
813
+ .append('pattern')
814
+ .attr('id', id)
815
+ .attr('patternUnits', 'userSpaceOnUse')
816
+ .attr('width', 8)
817
+ .attr('height', 8);
818
+ p.append('rect').attr('width', 8).attr('height', 8).attr('fill', color);
819
+ const hline = (yy) =>
820
+ p.append('line').attr('x1', 0).attr('y1', yy).attr('x2', 8).attr('y2', yy)
821
+ .attr('stroke', stroke).attr('stroke-width', sw);
822
+ const vline = (xx) =>
823
+ p.append('line').attr('x1', xx).attr('y1', 0).attr('x2', xx).attr('y2', 8)
824
+ .attr('stroke', stroke).attr('stroke-width', sw);
825
+ if (motif === 0) {
826
+ hline(2); hline(6); p.attr('patternTransform', 'rotate(45)');
827
+ }
828
+ else if (motif === 1) {
829
+ hline(2); hline(6); p.attr('patternTransform', 'rotate(-45)');
830
+ }
831
+ else if (motif === 2) {
832
+ p.append('circle').attr('cx', 4).attr('cy', 4).attr('r', 1.5).attr('fill', stroke);
833
+ }
834
+ else if (motif === 3) {
835
+ hline(4); vline(4);
836
+ }
837
+ else {
838
+ vline(2); vline(6);
839
+ }
840
+ return `url(#${id})`;
841
+ }
842
+
843
+ buildLegend() {
844
+ if (!this.legendEl || this.legendBuilt) return;
845
+ this.palette = this.palette || resolvePalette(this.root);
846
+ let series;
847
+ if (this.type === 'pie') {
848
+ const rows = this.lastDrawData || [];
849
+ series = rows.map((r, i) => ({
850
+ key: String(r[this.xKey]),
851
+ label: String(r[this.xKey]),
852
+ color: this.seriesColor(i, rows.length),
853
+ }));
854
+ }
855
+ else {
856
+ series = this.yKeys.map((k, i) => ({
857
+ key: String(k),
858
+ label: String(k),
859
+ color: this.seriesColor(i, this.yKeys.length),
860
+ }));
861
+ }
862
+
863
+ // A legend for a single series is noise (Carbon: omit it). Drop the
864
+ // empty list so it leaves no stray markup.
865
+ if (series.length < 2) {
866
+ this.legendEl.remove();
867
+ this.legendEl = null;
868
+ this.legendBuilt = true;
869
+ return;
870
+ }
871
+
872
+ const frag = document.createDocumentFragment();
873
+ this.legendButtons = new Map();
874
+ series.forEach((s, i) => {
875
+ const li = document.createElement('li');
876
+ li.setAttribute('role', 'none');
877
+ const btn = document.createElement('button');
878
+ btn.type = 'button';
879
+ btn.className = 'bdga-chart__legend-item';
880
+ btn.setAttribute('aria-pressed', 'true');
881
+ btn.dataset.bdgaKey = s.key;
882
+ const swatch = document.createElement('span');
883
+ swatch.className = 'bdga-chart__legend-swatch';
884
+ // Mirror the mark's texture so the key stays truthful.
885
+ if (this.texture) swatch.classList.add(`bdga-chart__legend-swatch--tex${i % 5}`);
886
+ swatch.setAttribute('aria-hidden', 'true');
887
+ swatch.style.backgroundColor = s.color;
888
+ const label = document.createElement('span');
889
+ label.className = 'bdga-chart__legend-label';
890
+ label.textContent = s.label;
891
+ btn.append(swatch, label);
892
+ btn.addEventListener('click', () => this.toggleSeries(s.key, s.label));
893
+ btn.addEventListener('mouseenter', () => this.emphasizeSeries(s.key));
894
+ btn.addEventListener('mouseleave', () => this.emphasizeSeries(null));
895
+ btn.addEventListener('focus', () => this.emphasizeSeries(s.key));
896
+ btn.addEventListener('blur', () => this.emphasizeSeries(null));
897
+ li.appendChild(btn);
898
+ frag.appendChild(li);
899
+ this.legendButtons.set(s.key, btn);
900
+ });
901
+ this.legendEl.replaceChildren(frag);
902
+ this.legendBuilt = true;
903
+ }
904
+
905
+ toggleSeries(key, label) {
906
+ const willHide = !this.hidden.has(key);
907
+ if (willHide && this.visibleSeriesCount() <= 1) {
908
+ this.setStatus(Drupal.t('At least one series must stay visible.'));
909
+ return;
910
+ }
911
+ if (willHide) this.hidden.add(key);
912
+ else this.hidden.delete(key);
913
+ const btn = this.legendButtons.get(key);
914
+ if (btn) {
915
+ btn.setAttribute('aria-pressed', String(!willHide));
916
+ btn.classList.toggle('bdga-chart__legend-item--hidden', willHide);
917
+ // Non-colour cue for the hidden state in the accessible name.
918
+ btn.setAttribute('aria-label', willHide ? Drupal.t('@s (hidden)', { '@s': label }) : label);
919
+ }
920
+ this.redraw();
921
+ this.setStatus(
922
+ willHide ? Drupal.t('@s hidden', { '@s': label }) : Drupal.t('@s shown', { '@s': label })
923
+ );
924
+ }
925
+
926
+ /**
927
+ * Highlight one series by dropping every other mark to 30% opacity. Inline
928
+ * styles are wiped on the next redraw, so a stale highlight can't persist
929
+ * past a toggle. key === null restores all.
930
+ */
931
+ emphasizeSeries(key) {
932
+ if (!this.canvas) return;
933
+ this.canvas.querySelectorAll('[data-bdga-series]').forEach((m) => {
934
+ m.style.opacity = key === null || m.getAttribute('data-bdga-series') === key ? '' : '0.3';
935
+ });
936
+ }
937
+
938
+ // -- Keyboard navigation of data points (Phase 3) ------------------------
939
+ //
940
+ // Each data mark is a real, labelled focusable element (role="img" +
941
+ // aria-label), the way @fluentui/react-charting exposes its marks - so a
942
+ // screen reader reads the point when focus lands on it, with no reliance on
943
+ // a live region for point-by-point narration. A roving-tabindex model gives
944
+ // the group a single tab stop; arrow keys then move between points (Left/
945
+ // Right within a series, Up/Down across series), Home/End jump to the ends.
946
+ // A visual tooltip mirrors the label on focus and hover. The data table
947
+ // remains the structural alternative; decorative axis/grid elements stay
948
+ // out of the a11y tree. Sankey/flow nodes keep their <title> tooltips and
949
+ // are not part of this point model.
950
+
951
+ formatValue(v) {
952
+ return typeof v === 'number' ? v.toLocaleString(this.locale || undefined) : String(v);
953
+ }
954
+
955
+ pointLabel(seriesLabel, xVal, value) {
956
+ const val = this.formatValue(value);
957
+ // Drop the series label when it would just repeat the category.
958
+ return seriesLabel && String(seriesLabel) !== String(xVal)
959
+ ? `${xVal}, ${seriesLabel}: ${val}`
960
+ : `${xVal}: ${val}`;
961
+ }
962
+
963
+ /**
964
+ * Register one series' marks for keyboard navigation. `entries` is an
965
+ * ordered array of { el, xVal, value, label? } in x order. Each element
966
+ * becomes a labelled, focusable point; the group is stored so arrow-key
967
+ * navigation can move within and across series.
968
+ */
969
+ addPoints(seriesLabel, entries) {
970
+ const group = [];
971
+ entries.forEach((e) => {
972
+ const label = e.label || this.pointLabel(seriesLabel, e.xVal, e.value);
973
+ e.el.setAttribute('role', 'img');
974
+ e.el.setAttribute('aria-label', label);
975
+ e.el.setAttribute('tabindex', '-1');
976
+ e.el.setAttribute('data-bdga-point', '');
977
+ this.points.push(e.el);
978
+ group.push(e.el);
979
+ });
980
+ if (group.length) this.pointGroups.push(group);
981
+ }
982
+
983
+ /**
984
+ * Finalise the point model after a draw: set position attributes, make the
985
+ * first point the single tab stop, and bind the keyboard / pointer / focus
986
+ * handlers once (the canvas element persists across redraws, so the
987
+ * listeners survive replaceChildren).
988
+ */
989
+ /**
990
+ * Expose the plot to assistive tech once its marks are individually
991
+ * labelled and focusable. draw() leaves the canvas aria-hidden ("table is
992
+ * the AT source"); this lifts that, silences the decorative axes /
993
+ * gridlines, and names the svg as a group. `instruction` is appended to the
994
+ * group label to tell the user how to explore (arrow keys vs Tab). Charts
995
+ * with no focusable marks must NOT call this - they stay aria-hidden so the
996
+ * table is the sole AT path.
997
+ */
998
+ exposePlot(instruction) {
999
+ this.canvas.removeAttribute('aria-hidden');
1000
+ this.canvas
1001
+ .querySelectorAll('.tick, .domain, .bdga-chart__axis-label')
1002
+ .forEach((el) => el.setAttribute('aria-hidden', 'true'));
1003
+ const svgEl = this.canvas.querySelector('svg');
1004
+ if (!svgEl) return;
1005
+ svgEl.setAttribute('role', 'group');
1006
+ const titleEl = this.root.querySelector('.bdga-chart__title');
1007
+ const name = (titleEl && titleEl.textContent.trim()) || this.id || Drupal.t('Chart');
1008
+ svgEl.setAttribute(
1009
+ 'aria-label',
1010
+ instruction ? Drupal.t('@t. @i', { '@t': name, '@i': instruction }) : name
1011
+ );
1012
+ }
1013
+
1014
+ initPointNav() {
1015
+ if (!this.points || !this.points.length) return;
1016
+
1017
+ this.exposePlot(Drupal.t('Use arrow keys to move between data points.'));
1018
+
1019
+ this.pointGroups.forEach((group) => {
1020
+ group.forEach((el, idx) => {
1021
+ el.setAttribute('aria-posinset', String(idx + 1));
1022
+ el.setAttribute('aria-setsize', String(group.length));
1023
+ });
1024
+ });
1025
+ this.focusPos = { g: 0, i: 0 };
1026
+ // Whether the user has actually focused a point this draw. Toolbar zoom
1027
+ // centres on the focused point only when this is true.
1028
+ this.pointFocused = false;
1029
+ this.pointGroups[0][0].setAttribute('tabindex', '0');
1030
+
1031
+ if (this.pointNavBound) return;
1032
+ this.pointNavBound = true;
1033
+ this.canvas.addEventListener('keydown', (e) => this.onPointKeydown(e));
1034
+ // Pointer + focus parity for the tooltip.
1035
+ this.canvas.addEventListener('mouseover', (e) => {
1036
+ const pt = e.target.closest('[data-bdga-point]');
1037
+ if (pt) this.showPointTooltip(pt);
1038
+ });
1039
+ this.canvas.addEventListener('mouseout', (e) => {
1040
+ const pt = e.target.closest('[data-bdga-point]');
1041
+ if (pt) this.hidePointTooltip();
1042
+ });
1043
+ this.canvas.addEventListener('focusin', (e) => {
1044
+ const pt = e.target.closest && e.target.closest('[data-bdga-point]');
1045
+ if (!pt) return;
1046
+ this.syncFocusPos(pt);
1047
+ this.showPointTooltip(pt);
1048
+ });
1049
+ this.canvas.addEventListener('focusout', (e) => {
1050
+ const pt = e.target.closest && e.target.closest('[data-bdga-point]');
1051
+ if (pt) this.hidePointTooltip();
1052
+ });
1053
+ }
1054
+
1055
+ /** Locate a focused point so arrow keys resume from it. */
1056
+ syncFocusPos(el) {
1057
+ for (let g = 0; g < this.pointGroups.length; g += 1) {
1058
+ const i = this.pointGroups[g].indexOf(el);
1059
+ if (i !== -1) {
1060
+ if (this.focusPos) {
1061
+ const prev = this.pointGroups[this.focusPos.g] &&
1062
+ this.pointGroups[this.focusPos.g][this.focusPos.i];
1063
+ if (prev && prev !== el) prev.setAttribute('tabindex', '-1');
1064
+ }
1065
+ el.setAttribute('tabindex', '0');
1066
+ this.focusPos = { g, i };
1067
+ this.pointFocused = true;
1068
+ return;
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ onPointKeydown(e) {
1074
+ if (!this.pointGroups.length || !this.focusPos) return;
1075
+
1076
+ // Keyboard zoom while a point is focused: +/- around it, 0 resets.
1077
+ if (this.applicableZoom()) {
1078
+ if (e.key === '+' || e.key === '=') {
1079
+ e.preventDefault();
1080
+ this.zoomIn(this.currentFullIndex(), true);
1081
+ return;
1082
+ }
1083
+ if (e.key === '-' || e.key === '_') {
1084
+ e.preventDefault();
1085
+ this.zoomOut(this.currentFullIndex(), true);
1086
+ return;
1087
+ }
1088
+ if (e.key === '0') {
1089
+ e.preventDefault();
1090
+ // Reset to the full extent but keep focus on the same point.
1091
+ this.zoomReset(this.currentFullIndex(), true);
1092
+ return;
1093
+ }
1094
+ }
1095
+
1096
+ let { g, i } = this.focusPos;
1097
+ const groups = this.pointGroups;
1098
+ switch (e.key) {
1099
+ case 'ArrowRight':
1100
+ i = Math.min(i + 1, groups[g].length - 1);
1101
+ break;
1102
+ case 'ArrowLeft':
1103
+ i = Math.max(i - 1, 0);
1104
+ break;
1105
+ case 'ArrowDown':
1106
+ g = Math.min(g + 1, groups.length - 1);
1107
+ i = Math.min(i, groups[g].length - 1);
1108
+ break;
1109
+ case 'ArrowUp':
1110
+ g = Math.max(g - 1, 0);
1111
+ i = Math.min(i, groups[g].length - 1);
1112
+ break;
1113
+ case 'Home':
1114
+ i = 0;
1115
+ break;
1116
+ case 'End':
1117
+ i = groups[g].length - 1;
1118
+ break;
1119
+ case 'Escape':
1120
+ this.hidePointTooltip();
1121
+ return;
1122
+ default:
1123
+ return;
1124
+ }
1125
+ e.preventDefault();
1126
+ this.focusPoint(g, i);
1127
+ }
1128
+
1129
+ focusPoint(g, i) {
1130
+ const groups = this.pointGroups;
1131
+ const prev = groups[this.focusPos.g] && groups[this.focusPos.g][this.focusPos.i];
1132
+ if (prev) prev.setAttribute('tabindex', '-1');
1133
+ this.focusPos = { g, i };
1134
+ this.pointFocused = true;
1135
+ const el = groups[g][i];
1136
+ el.setAttribute('tabindex', '0');
1137
+ el.focus();
1138
+ this.showPointTooltip(el);
1139
+ }
1140
+
1141
+ showPointTooltip(el) {
1142
+ if (!this.canvas) return;
1143
+ let tip = this.tooltipEl;
1144
+ if (!tip) {
1145
+ tip = document.createElement('div');
1146
+ tip.className = 'bdga-chart__tooltip';
1147
+ tip.setAttribute('aria-hidden', 'true');
1148
+ this.canvas.appendChild(tip);
1149
+ this.tooltipEl = tip;
1150
+ }
1151
+ tip.textContent = el.getAttribute('aria-label') || '';
1152
+ // Position relative to the canvas, centred above the mark.
1153
+ const cRect = this.canvas.getBoundingClientRect();
1154
+ const mRect = el.getBoundingClientRect();
1155
+ const left = mRect.left - cRect.left + mRect.width / 2;
1156
+ const top = mRect.top - cRect.top;
1157
+ tip.style.left = `${left}px`;
1158
+ tip.style.top = `${top}px`;
1159
+ tip.dataset.visible = 'true';
1160
+ }
1161
+
1162
+ hidePointTooltip() {
1163
+ if (this.tooltipEl) delete this.tooltipEl.dataset.visible;
1164
+ }
1165
+
1166
+ readTable() {
1167
+ if (!this.tableEl) return [];
1168
+ // Prefer the canonical data key from the <th data-bdga-key> attribute;
1169
+ // fall back to text content for backward compat with hand-written markup.
1170
+ const headerKeys = Array.from(this.tableEl.querySelectorAll('thead th')).map(
1171
+ (th) => (th.dataset && th.dataset.bdgaKey) || th.textContent.trim()
1172
+ );
1173
+ if (!this.xKey && headerKeys[0]) this.xKey = headerKeys[0];
1174
+ if (!this.yKeys.length) {
1175
+ this.yKeys = headerKeys.slice(1).filter(Boolean);
1176
+ }
1177
+ return Array.from(this.tableEl.querySelectorAll('tbody tr'))
1178
+ .slice(0, MAX_ROWS)
1179
+ .map((tr) => {
1180
+ const cells = tr.querySelectorAll('th, td');
1181
+ const row = {};
1182
+ cells.forEach((cell, i) => {
1183
+ const key = (cell.dataset && cell.dataset.bdgaKey) || headerKeys[i] || (`col_${ i}`);
1184
+ const raw =
1185
+ cell.dataset && cell.dataset.value !== undefined
1186
+ ? cell.dataset.value
1187
+ : cell.textContent;
1188
+ row[key] = raw.trim();
1189
+ });
1190
+ this.yKeys.forEach((y) => {
1191
+ const n = Number(row[y]);
1192
+ row[y] = Number.isFinite(n) ? n : 0;
1193
+ });
1194
+ row[this.xKey] = String(row[this.xKey]);
1195
+ return row;
1196
+ });
1197
+ }
1198
+
1199
+ async loadFromUrl() {
1200
+ if (!this.url) return this.fail('Missing source URL');
1201
+ let url;
1202
+ try {
1203
+ url = new URL(this.url);
1204
+ } catch {
1205
+ return this.fail('Invalid URL');
1206
+ }
1207
+ if (url.protocol !== 'https:') return this.fail('Insecure URL scheme');
1208
+ if (!ALLOWED_HOSTS.includes(url.hostname)) {
1209
+ return this.fail(`Host not on allowlist: ${ url.hostname}`);
1210
+ }
1211
+
1212
+ this.setStatus(Drupal.t('Loading chart data...'));
1213
+
1214
+ const controller = new AbortController();
1215
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1216
+
1217
+ let payload;
1218
+ try {
1219
+ const res = await fetch(url.toString(), {
1220
+ method: 'GET',
1221
+ credentials: 'omit',
1222
+ referrerPolicy: 'no-referrer',
1223
+ mode: 'cors',
1224
+ signal: controller.signal,
1225
+ headers: { Accept: 'application/json' },
1226
+ });
1227
+ clearTimeout(timer);
1228
+ if (!res.ok) return this.fail(`HTTP ${ res.status}`);
1229
+ const ct = (res.headers.get('content-type') || '').toLowerCase();
1230
+ if (ct.indexOf('application/json') === -1) {
1231
+ return this.fail(`Unexpected content-type: ${ ct}`);
1232
+ }
1233
+ payload = await res.json();
1234
+ } catch (err) {
1235
+ clearTimeout(timer);
1236
+ return this.fail(`Fetch failed: ${ err.message || err}`);
1237
+ }
1238
+
1239
+ const rows = this.extractCkanRows(payload);
1240
+ if (!rows.length) return this.fail('No rows returned');
1241
+
1242
+ // Sankey / flow: the wire shape is a list of {source, target, value}
1243
+ // rows. Build nodes + links here so the renderer path is identical
1244
+ // to JSON-mode flow charts.
1245
+ if (this.type === 'sankey' || this.type === 'flow') {
1246
+ const graph = this.buildSankeyFromRows(rows);
1247
+ if (!graph.links.length) {
1248
+ return this.fail('CKAN response had no usable source/target/value rows');
1249
+ }
1250
+ if (typeof window.d3.sankey !== 'function') {
1251
+ return this.fail('d3-sankey plugin missing');
1252
+ }
1253
+ this.nodes = graph.nodes;
1254
+ this.links = graph.links;
1255
+ this.populateFlowTable(graph.links);
1256
+ this.updateFlowTableHeaders(graph.nodes);
1257
+ this.setStatus(
1258
+ Drupal.t('Chart loaded. @count flows, @nodes nodes.', {
1259
+ '@count': graph.links.length,
1260
+ '@nodes': graph.nodes.length,
1261
+ })
1262
+ );
1263
+ return this.draw([]);
1264
+ }
1265
+
1266
+ this.populateTable(rows);
1267
+ this.setStatus(
1268
+ Drupal.t('Chart loaded. @count rows.', { '@count': rows.length })
1269
+ );
1270
+ this.draw(rows);
1271
+ }
1272
+
1273
+ extractCkanRows(payload) {
1274
+ // CKAN datastore_search response: { result: { records: [...] } }
1275
+ const records =
1276
+ payload && payload.result && Array.isArray(payload.result.records)
1277
+ ? payload.result.records
1278
+ : Array.isArray(payload)
1279
+ ? payload
1280
+ : [];
1281
+ const isFlow = this.type === 'sankey' || this.type === 'flow';
1282
+ const yKeySet = new Set(this.yKeys || []);
1283
+ // For flow types the y-key set is fixed; the wire shape carries a
1284
+ // single numeric column called 'value' regardless of authored y_keys.
1285
+ if (isFlow) {
1286
+ yKeySet.clear();
1287
+ yKeySet.add('value');
1288
+ }
1289
+ return records.slice(0, MAX_ROWS).map((r) => {
1290
+ const out = {};
1291
+ // Preserve EVERY column from the source record so downstream renderers
1292
+ // (e.g. lollipop's color_by:category lookup) can find non-axis fields
1293
+ // like Tier. Numeric Y columns are coerced to Number; everything
1294
+ // else (including string columns and the X key) stays as a string.
1295
+ // Objects/arrays in the record are dropped: extractCkanRows is a
1296
+ // sanitisation boundary, not a generic deep clone.
1297
+ //
1298
+ // MAX_ROWS caps row count, MAX_CELL_CHARS caps each string cell.
1299
+ // Together they bound the payload that lands in the table fallback
1300
+ // and any downstream renderer; authors should still constrain their
1301
+ // SELECT to the columns they need (Project name, Tier, etc.) rather
1302
+ // than SELECT * against a wide CKAN resource.
1303
+ Object.keys(r || {}).forEach((k) => {
1304
+ const v = r[k];
1305
+ if (v === null || v === undefined) {
1306
+ out[k] = '';
1307
+ return;
1308
+ }
1309
+ if (typeof v === 'object') {
1310
+ // Skip nested structures - we never plot them and they'd
1311
+ // serialise as "[object Object]" if cast to string.
1312
+ return;
1313
+ }
1314
+ if (yKeySet.has(k)) {
1315
+ const n = Number(v);
1316
+ out[k] = Number.isFinite(n) ? n : 0;
1317
+ return;
1318
+ }
1319
+ const s = String(v);
1320
+ out[k] = s.length > MAX_CELL_CHARS ? `${s.slice(0, MAX_CELL_CHARS) }…` : s;
1321
+ });
1322
+ return out;
1323
+ });
1324
+ }
1325
+
1326
+ /**
1327
+ * Transform a CKAN response into {nodes, links} for d3-sankey.
1328
+ *
1329
+ * Two wire shapes are supported, distinguished by the first record's
1330
+ * keys:
1331
+ *
1332
+ * (a) Flat-row: `{source, target, value}` per row. Mirrors the
1333
+ * server-side _bdga_chart_parse_sankey_json flat-row branch.
1334
+ * Self-loops, non-positive values, and missing source/target
1335
+ * are skipped.
1336
+ *
1337
+ * (b) Wide-cascade: `{stage1, stage2, ..., stageN, value}` per row.
1338
+ * Each row produces N-1 links chaining adjacent stages, with
1339
+ * node ids prefixed by the column name so values that repeat
1340
+ * across stages (e.g. "High" in 2024 and 2026) don't collapse
1341
+ * into a single node. Null / empty cells drop that stage's
1342
+ * adjacency rather than synthesising a placeholder node; if
1343
+ * fewer than 2 stages survive in a row, the row contributes
1344
+ * no links. Duplicate (source, target) pairs are summed.
1345
+ *
1346
+ * Node order in the returned array is the order ids are first seen,
1347
+ * which keeps the d3-sankey layout stable across reloads.
1348
+ */
1349
+ buildSankeyFromRows(rows) {
1350
+ if (!rows.length) return { nodes: [], links: [] };
1351
+ const first = rows[0];
1352
+ const hasFlatKeys = 'source' in first && 'target' in first && 'value' in first;
1353
+ if (hasFlatKeys) {
1354
+ return this.buildSankeyFromFlatRows(rows);
1355
+ }
1356
+ // Stage columns = every key except `value`, in insertion order.
1357
+ // CKAN preserves SELECT-clause order so this matches the SQL the
1358
+ // author wrote.
1359
+ const stageCols = Object.keys(first).filter((k) => k !== 'value');
1360
+ if (stageCols.length < 2) return { nodes: [], links: [] };
1361
+ return this.buildSankeyFromCascadeRows(rows, stageCols);
1362
+ }
1363
+
1364
+ buildSankeyFromFlatRows(rows) {
1365
+ // d3-sankey is a DAG layout: it cannot render edges where source ===
1366
+ // target, and the row carries genuine signal (e.g. 12 projects whose
1367
+ // DCA rating stayed at Medium-High between 2025 and 2026). When the
1368
+ // author writes a 2-stage SQL like
1369
+ // SELECT "DCA 2025" AS source, "DCA 2026" AS target, COUNT(*) AS value
1370
+ // the labels overlap. We pre-scan for that collision and, if it
1371
+ // occurs anywhere, auto-prefix every row's source/target with
1372
+ // generic "From: " / "To: " markers so the data survives. Authors
1373
+ // who want nicer node labels can prefix at SQL time, e.g.
1374
+ // SELECT '2025: ' || "DCA 2025" AS source,
1375
+ // '2026: ' || "DCA 2026" AS target,
1376
+ // in which case the pre-scan finds no collision and the labels pass
1377
+ // through untouched.
1378
+ let hasCollision = false;
1379
+ for (let i = 0; i < rows.length; i += 1) {
1380
+ const r = rows[i];
1381
+ const s = String(r.source ?? '').trim();
1382
+ const t = String(r.target ?? '').trim();
1383
+ if (s && t && s === t) {
1384
+ hasCollision = true;
1385
+ break;
1386
+ }
1387
+ }
1388
+
1389
+ const seen = new Set();
1390
+ const nodes = [];
1391
+ const links = [];
1392
+ let droppedSelfLoops = 0;
1393
+ rows.forEach((r) => {
1394
+ let src = String(r.source ?? '').trim();
1395
+ let tgt = String(r.target ?? '').trim();
1396
+ const val = Number(r.value);
1397
+ if (!src || !tgt) return;
1398
+ if (!Number.isFinite(val) || val <= 0) return;
1399
+ if (hasCollision) {
1400
+ src = `From: ${ src}`;
1401
+ tgt = `To: ${ tgt}`;
1402
+ }
1403
+ if (src === tgt) {
1404
+ // Should not occur after auto-prefixing; left as a guard.
1405
+ droppedSelfLoops += 1;
1406
+ return;
1407
+ }
1408
+ [src, tgt].forEach((id) => {
1409
+ if (!seen.has(id)) {
1410
+ seen.add(id);
1411
+ nodes.push({ id });
1412
+ }
1413
+ });
1414
+ links.push({ source: src, target: tgt, value: val });
1415
+ });
1416
+ if (droppedSelfLoops && window.console) {
1417
+ window.console.warn(`[bdga-chart] ${ this.id }: dropped ${ droppedSelfLoops } self-loop rows`);
1418
+ }
1419
+ return { nodes, links };
1420
+ }
1421
+
1422
+ buildSankeyFromCascadeRows(rows, stageCols) {
1423
+ const seen = new Set();
1424
+ const nodes = [];
1425
+ const linkMap = new Map();
1426
+ // Stage column -> integer index in author SELECT order. Captured here
1427
+ // and attached to each node so drawSankeyInternal can force the
1428
+ // d3-sankey layer assignment directly, rather than guessing stage
1429
+ // order from the order chains happen to enter the link map. Leading
1430
+ // null rows in the CKAN response would otherwise seed the first
1431
+ // non-null column as layer 0 regardless of where it semantically
1432
+ // belongs - that bug had the 2025 column rendering leftmost and the
1433
+ // 2024 column rendering rightmost.
1434
+ const stageOf = new Map(stageCols.map((c, i) => [c, i]));
1435
+ const addNode = (id, stage) => {
1436
+ if (!seen.has(id)) {
1437
+ seen.add(id);
1438
+ nodes.push({ id, stage });
1439
+ }
1440
+ };
1441
+ rows.forEach((r) => {
1442
+ const val = Number(r.value);
1443
+ if (!Number.isFinite(val) || val <= 0) return;
1444
+ // Resolve each stage label, dropping nulls/empties. The prefix
1445
+ // keeps stage-N nodes distinct from stage-M nodes when their
1446
+ // labels overlap (e.g. "High" appears in every DCA year column).
1447
+ const chain = [];
1448
+ stageCols.forEach((c) => {
1449
+ const v = r[c];
1450
+ if (v === null || v === undefined || v === '') return;
1451
+ chain.push({ id: `${c }: ${ String(v).trim()}`, stage: stageOf.get(c) });
1452
+ });
1453
+ if (chain.length < 2) return;
1454
+ chain.forEach((step) => addNode(step.id, step.stage));
1455
+ for (let i = 0; i < chain.length - 1; i += 1) {
1456
+ const src = chain[i].id;
1457
+ const tgt = chain[i + 1].id;
1458
+ if (src === tgt) {
1459
+ // Self-loops happen when a project's rating didn't change between
1460
+ // adjacent stages. d3-sankey rejects self-edges, so we drop
1461
+ // them - they're already implied by the unchanged column
1462
+ // position in the layout.
1463
+ continue;
1464
+ }
1465
+ const key = `${src }${ tgt}`;
1466
+ const existing = linkMap.get(key);
1467
+ if (existing) {
1468
+ existing.value += val;
1469
+ }
1470
+ else {
1471
+ linkMap.set(key, { source: src, target: tgt, value: val });
1472
+ }
1473
+ }
1474
+ });
1475
+ return { nodes, links: Array.from(linkMap.values()) };
1476
+ }
1477
+
1478
+ populateTable(rows) {
1479
+ if (!this.tableEl) return;
1480
+ const tbody = this.tableEl.querySelector('tbody');
1481
+ if (!tbody) return;
1482
+ // textContent only; no HTML.
1483
+ const frag = document.createDocumentFragment();
1484
+ rows.forEach((row) => {
1485
+ const tr = document.createElement('tr');
1486
+ const th = document.createElement('th');
1487
+ th.setAttribute('scope', 'row');
1488
+ th.textContent = row[this.xKey];
1489
+ tr.appendChild(th);
1490
+ this.yKeys.forEach((y) => {
1491
+ const td = document.createElement('td');
1492
+ td.dataset.value = String(row[y]);
1493
+ td.textContent = String(row[y]);
1494
+ tr.appendChild(td);
1495
+ });
1496
+ frag.appendChild(tr);
1497
+ });
1498
+ tbody.replaceChildren(frag);
1499
+ }
1500
+
1501
+ /**
1502
+ * Sankey/flow fallback table. Three columns: source, target, value.
1503
+ * Server-side rendering already emits the matching <thead>; this only
1504
+ * fills <tbody> after a URL-mode fetch.
1505
+ */
1506
+ populateFlowTable(links) {
1507
+ if (!this.tableEl) return;
1508
+ const tbody = this.tableEl.querySelector('tbody');
1509
+ if (!tbody) return;
1510
+ const frag = document.createDocumentFragment();
1511
+ links.forEach((l) => {
1512
+ const tr = document.createElement('tr');
1513
+ const th = document.createElement('th');
1514
+ th.setAttribute('scope', 'row');
1515
+ th.textContent = String(l.source);
1516
+ tr.appendChild(th);
1517
+ const tdTarget = document.createElement('td');
1518
+ tdTarget.textContent = String(l.target);
1519
+ tr.appendChild(tdTarget);
1520
+ const tdValue = document.createElement('td');
1521
+ tdValue.dataset.value = String(l.value);
1522
+ tdValue.textContent = String(l.value);
1523
+ tr.appendChild(tdValue);
1524
+ frag.appendChild(tr);
1525
+ });
1526
+ tbody.replaceChildren(frag);
1527
+ }
1528
+
1529
+ /**
1530
+ * Rewrite the source / target column headers of the fallback table
1531
+ * using prefixes detected on the nodes. Mirrors the PHP helper
1532
+ * _bdga_chart_sankey_table_labels: a clean 2-stage graph (every node
1533
+ * "prefix: label", exactly two distinct prefixes) emits its prefixes
1534
+ * as headers; anything else leaves the server-rendered defaults
1535
+ * ("From" / "To") in place. The value column is the y_label fallback
1536
+ * already baked into the template by Twig.
1537
+ *
1538
+ * Only the URL-mode path needs this - JSON-mode flow charts have
1539
+ * their headers computed server-side and rendered correctly on first
1540
+ * paint.
1541
+ */
1542
+ updateFlowTableHeaders(nodes) {
1543
+ if (!this.tableEl || !nodes || !nodes.length) return;
1544
+ const prefixes = [];
1545
+ for (let i = 0; i < nodes.length; i += 1) {
1546
+ const id = String(nodes[i].id || '');
1547
+ const sep = id.indexOf(': ');
1548
+ if (sep === -1) return; // mixed shape - keep defaults
1549
+ const p = id.slice(0, sep);
1550
+ if (prefixes.indexOf(p) === -1) {
1551
+ prefixes.push(p);
1552
+ if (prefixes.length > 2) return; // 3+ stages - keep generic headers
1553
+ }
1554
+ }
1555
+ if (prefixes.length !== 2) return;
1556
+ const srcTh = this.tableEl.querySelector('thead [data-bdga-key="source"]');
1557
+ const tgtTh = this.tableEl.querySelector('thead [data-bdga-key="target"]');
1558
+ if (srcTh) srcTh.textContent = prefixes[0];
1559
+ if (tgtTh) tgtTh.textContent = prefixes[1];
1560
+ }
1561
+
1562
+ draw(rows) {
1563
+ // Remember the inputs and the width we drew at so the ResizeObserver can
1564
+ // re-lay-out crisply on a container-width change. URL-mode reuses these
1565
+ // fetched rows on redraw - it never refetches.
1566
+ this.lastDrawData = rows;
1567
+ this.lastDrawWidth = this.canvas.clientWidth || 640;
1568
+
1569
+ // Wipe any previous render and reveal the canvas to sighted users.
1570
+ this.canvas.replaceChildren();
1571
+ this.canvas.removeAttribute('aria-hidden');
1572
+ this.canvas.setAttribute('aria-hidden', 'true'); // table is the AT source.
1573
+
1574
+ // Resolve palette from CSS once per draw (getComputedStyle is fast).
1575
+ this.palette = resolvePalette(this.root);
1576
+
1577
+ // Build the legend on first draw (needs palette + data); the guard makes
1578
+ // it a no-op on subsequent redraws.
1579
+ this.buildLegend();
1580
+
1581
+ // Reset per-draw keyboard-nav state. Renderers register their marks via
1582
+ // addPoints(); initPointNav() finalises the roving model afterwards.
1583
+ this.points = [];
1584
+ this.pointGroups = [];
1585
+ // Pattern <defs> live inside each freshly built svg, so the id cache
1586
+ // resets per draw.
1587
+ this.patternIds = new Set();
1588
+
1589
+ // Apply data-domain zoom for the ordinal cartesian types. lastDrawData
1590
+ // keeps the full set (so zoom-out restores and downloads stay complete);
1591
+ // only the rows handed to the renderer are windowed.
1592
+ const drawRows = this.applicableZoom() ? this.sliceZoom(rows) : rows;
1593
+
1594
+ switch (this.type) {
1595
+ case 'line':
1596
+ this.drawLine(drawRows);
1597
+ break;
1598
+ case 'pie':
1599
+ this.drawPie(drawRows);
1600
+ break;
1601
+ case 'stacked_bar':
1602
+ this.drawStackedBar(drawRows);
1603
+ break;
1604
+ case 'grouped_bar':
1605
+ this.drawGroupedBar(drawRows);
1606
+ break;
1607
+ case 'sankey':
1608
+ this.drawSankey();
1609
+ break;
1610
+ case 'flow':
1611
+ this.drawFlow();
1612
+ break;
1613
+ case 'lollipop':
1614
+ this.drawLollipop(drawRows);
1615
+ break;
1616
+ case 'cleveland':
1617
+ this.drawCleveland(drawRows);
1618
+ break;
1619
+ case 'bar':
1620
+ default:
1621
+ this.drawBar(drawRows);
1622
+ break;
1623
+ }
1624
+
1625
+ this.initPointNav();
1626
+ }
1627
+
1628
+ /**
1629
+ * Decide whether X-axis labels need rotation, based on category count and
1630
+ * label length. Returns null when no rotation needed (stays horizontal).
1631
+ */
1632
+ xAxisRotation(rows) {
1633
+ if (!rows || !rows.length) return null;
1634
+ const labels = rows.map((r) => String(r[this.xKey] || ''));
1635
+ const maxLen = labels.reduce((acc, s) => Math.max(acc, s.length), 0);
1636
+ const count = labels.length;
1637
+ // Heuristic: rotate if too many categories OR any label is long enough
1638
+ // to collide with neighbours.
1639
+ if (count > 6 || maxLen > 14) return -30;
1640
+ return null;
1641
+ }
1642
+
1643
+ /**
1644
+ * Bottom margin grows when labels are rotated, to leave room for the
1645
+ * angled text without clipping. Extra space is also reserved for the
1646
+ * axis title text emitted by drawAxisLabels: ~22px below the X axis,
1647
+ * ~24px to the left of the Y axis when those titles are present.
1648
+ */
1649
+ dims(rows) {
1650
+ const w = this.canvas.clientWidth || 640;
1651
+ const h = Math.min(Math.max(w * 0.5, 280), 480);
1652
+ const rotation = this.xAxisRotation(rows);
1653
+ const maxLen = rows
1654
+ ? rows.reduce((acc, r) => Math.max(acc, String(r[this.xKey] || '').length), 0)
1655
+ : 0;
1656
+ const xLabelExtra = this.xLabel ? 22 : 0;
1657
+ const yLabelExtra = this.yLabel ? 24 : 0;
1658
+ const bottom = (rotation
1659
+ ? Math.min(48 + Math.ceil(maxLen * 4.2), 160)
1660
+ : 48) + xLabelExtra;
1661
+ return {
1662
+ w,
1663
+ h: rotation ? h + (bottom - 48 - xLabelExtra) + xLabelExtra : h + xLabelExtra,
1664
+ m: { top: 16, right: 24, bottom, left: 56 + yLabelExtra },
1665
+ rotation,
1666
+ };
1667
+ }
1668
+
1669
+ /**
1670
+ * Append the X and Y axis title text to an SVG root. Called by every chart
1671
+ * type that has axes (pie skips this). Reads this.xLabel / this.yLabel set
1672
+ * in the constructor; empty strings render nothing. Classes drive font /
1673
+ * fill via chart.css; no inline styling here.
1674
+ */
1675
+ drawAxisLabels(svg, w, h, m) {
1676
+ if (this.xLabel) {
1677
+ svg
1678
+ .append('text')
1679
+ .attr('class', 'bdga-chart__axis-label bdga-chart__axis-label--x')
1680
+ .attr('x', m.left + (w - m.left - m.right) / 2)
1681
+ .attr('y', h - 6)
1682
+ .attr('text-anchor', 'middle')
1683
+ .text(this.xLabel);
1684
+ }
1685
+ if (this.yLabel) {
1686
+ // Rotated -90deg around (0,0); x/y are in the rotated frame, so x is
1687
+ // the vertical position (negated) and y is the horizontal position.
1688
+ svg
1689
+ .append('text')
1690
+ .attr('class', 'bdga-chart__axis-label bdga-chart__axis-label--y')
1691
+ .attr('transform', 'rotate(-90)')
1692
+ .attr('x', -(m.top + (h - m.top - m.bottom) / 2))
1693
+ .attr('y', 14)
1694
+ .attr('text-anchor', 'middle')
1695
+ .text(this.yLabel);
1696
+ }
1697
+ }
1698
+
1699
+ /**
1700
+ * Apply rotation to the tick labels on the last-rendered X axis group.
1701
+ * Truncates labels longer than 28 chars with an ellipsis, keeping the
1702
+ * full text in a <title> child so screen-reader / hover users see all.
1703
+ */
1704
+ rotateXLabels(svg, rotation) {
1705
+ if (!rotation) return;
1706
+ const ticks = svg.selectAll('g.tick text').nodes();
1707
+ ticks.forEach((t) => {
1708
+ const full = t.textContent;
1709
+ if (full.length > 28) {
1710
+ t.textContent = `${full.slice(0, 27) }…`;
1711
+ const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
1712
+ title.textContent = full;
1713
+ t.parentNode.appendChild(title);
1714
+ }
1715
+ t.setAttribute('text-anchor', 'end');
1716
+ t.setAttribute('transform', `translate(-8,4) rotate(${ rotation })`);
1717
+ });
1718
+ }
1719
+
1720
+ svgRoot(w, h) {
1721
+ return window.d3
1722
+ .select(this.canvas)
1723
+ .append('svg')
1724
+ .attr('viewBox', `0 0 ${ w } ${ h}`)
1725
+ .attr('role', 'presentation')
1726
+ .attr('focusable', 'false');
1727
+ }
1728
+
1729
+ drawBar(rows) {
1730
+ const d3 = window.d3;
1731
+ const { w, h, m, rotation } = this.dims(rows);
1732
+ const svg = this.svgRoot(w, h);
1733
+ const yKey = this.yKeys[0];
1734
+
1735
+ const x = d3
1736
+ .scaleBand()
1737
+ .domain(rows.map((r) => r[this.xKey]))
1738
+ .range([m.left, w - m.right])
1739
+ .padding(0.15);
1740
+ const y = d3
1741
+ .scaleLinear()
1742
+ .domain([0, d3.max(rows, (r) => r[yKey]) || 1])
1743
+ .nice()
1744
+ .range([h - m.bottom, m.top]);
1745
+
1746
+ const xAxis = svg
1747
+ .append('g')
1748
+ .attr('transform', `translate(0,${ h - m.bottom })`)
1749
+ .call(d3.axisBottom(x));
1750
+ svg.append('g').attr('transform', `translate(${ m.left },0)`).call(d3.axisLeft(y));
1751
+ this.rotateXLabels(xAxis, rotation);
1752
+
1753
+ // Single-series bar charts default to one colour (palette.single) per
1754
+ // IBM Carbon convention: for time-series bars the colour carries no
1755
+ // information and rainbow bars are visual noise. Authors opt into
1756
+ // per-category colouring via field_bdga_p_chart_color_by when the X
1757
+ // axis is categorical (e.g. agency types) and colour reinforces the
1758
+ // distinction between bars.
1759
+ const colorByCategory = this.colorBy === 'category' && this.yKeys.length === 1;
1760
+ const palette = this.palette;
1761
+ const barFill = colorByCategory
1762
+ ? (_d, i) =>
1763
+ i < palette.categorical.length
1764
+ ? palette.categorical[i]
1765
+ : shadeSequential(palette, i, rows.length)
1766
+ : palette.single;
1767
+
1768
+ const bars = svg
1769
+ .append('g')
1770
+ .selectAll('rect')
1771
+ .data(rows)
1772
+ .join('rect')
1773
+ .attr('x', (d) => x(d[this.xKey]))
1774
+ .attr('y', (d) => y(d[yKey]))
1775
+ .attr('width', x.bandwidth())
1776
+ .attr('height', (d) => y(0) - y(d[yKey]))
1777
+ .attr('fill', barFill);
1778
+
1779
+ this.addPoints(
1780
+ this.yLabel || yKey,
1781
+ bars.nodes().map((el) => {
1782
+ const d = d3.select(el).datum();
1783
+ return { el, xVal: d[this.xKey], value: d[yKey] };
1784
+ })
1785
+ );
1786
+
1787
+ this.drawAxisLabels(svg, w, h, m);
1788
+ }
1789
+
1790
+ /**
1791
+ * Grouped bar chart - multiple Y series rendered side-by-side within each
1792
+ * X category. Uses two d3.scaleBand instances (outer for categories,
1793
+ * inner for series). Single-series data degenerates cleanly to one bar
1794
+ * per category, matching drawBar.
1795
+ */
1796
+ drawGroupedBar(rows) {
1797
+ const d3 = window.d3;
1798
+ const { w, h, m, rotation } = this.dims(rows);
1799
+ const svg = this.svgRoot(w, h);
1800
+ // Only the visible series are plotted; colours stay keyed to the full
1801
+ // y-key list so a series keeps its colour when others are toggled off.
1802
+ const keys = this.visibleKeys();
1803
+
1804
+ const x0 = d3
1805
+ .scaleBand()
1806
+ .domain(rows.map((r) => r[this.xKey]))
1807
+ .range([m.left, w - m.right])
1808
+ .paddingInner(0.2);
1809
+
1810
+ const x1 = d3
1811
+ .scaleBand()
1812
+ .domain(keys)
1813
+ .range([0, x0.bandwidth()])
1814
+ .padding(0.05);
1815
+
1816
+ const yMax = d3.max(rows, (r) => d3.max(keys, (k) => r[k])) || 1;
1817
+ const y = d3.scaleLinear().domain([0, yMax]).nice().range([h - m.bottom, m.top]);
1818
+
1819
+ // Same palette policy as stacked bar: categorical in order, sequential
1820
+ // fallback when series count exceeds the palette.
1821
+ const useSequential = this.yKeys.length > this.palette.categorical.length;
1822
+ const palette = this.palette;
1823
+ const color = useSequential
1824
+ ? (key) => shadeSequential(palette, this.yKeys.indexOf(key), this.yKeys.length)
1825
+ : d3.scaleOrdinal().domain(this.yKeys).range(palette.categorical);
1826
+
1827
+ const xAxis = svg
1828
+ .append('g')
1829
+ .attr('transform', `translate(0,${ h - m.bottom })`)
1830
+ .call(d3.axisBottom(x0));
1831
+ svg.append('g').attr('transform', `translate(${ m.left },0)`).call(d3.axisLeft(y));
1832
+ this.rotateXLabels(xAxis, rotation);
1833
+
1834
+ const groupRects = svg
1835
+ .append('g')
1836
+ .selectAll('g')
1837
+ .data(rows)
1838
+ .join('g')
1839
+ .attr('transform', (d) => `translate(${ x0(d[this.xKey]) },0)`)
1840
+ .selectAll('rect')
1841
+ .data((d) => keys.map((key) => ({ key, value: +d[key] || 0 })))
1842
+ .join('rect')
1843
+ .attr('x', (d) => x1(d.key))
1844
+ .attr('y', (d) => y(d.value))
1845
+ .attr('width', x1.bandwidth())
1846
+ .attr('height', (d) => y(0) - y(d.value))
1847
+ .attr('data-bdga-series', (d) => d.key)
1848
+ .attr('fill', (d) => this.fillFor(svg, this.yKeys.indexOf(d.key), color(d.key)));
1849
+
1850
+ // Register one navigable group per visible series. The inner rect's datum
1851
+ // is { key, value }; its parent <g> holds the row, so the category label
1852
+ // comes from the parent's datum.
1853
+ const groupRectNodes = groupRects.nodes();
1854
+ keys.forEach((key) => {
1855
+ const entries = groupRectNodes
1856
+ .map((el) => ({ el, d: d3.select(el).datum(), row: d3.select(el.parentNode).datum() }))
1857
+ .filter((o) => o.d.key === key)
1858
+ .map((o) => ({ el: o.el, xVal: o.row[this.xKey], value: o.d.value }));
1859
+ this.addPoints(key, entries);
1860
+ });
1861
+
1862
+ this.drawAxisLabels(svg, w, h, m);
1863
+ }
1864
+
1865
+ drawStackedBar(rows) {
1866
+ const d3 = window.d3;
1867
+ const { w, h, m, rotation } = this.dims(rows);
1868
+ const svg = this.svgRoot(w, h);
1869
+ // Stack only the visible series; the stack re-bases from zero so hiding a
1870
+ // series cleanly removes its band.
1871
+ const series = d3.stack().keys(this.visibleKeys())(rows);
1872
+
1873
+ const x = d3
1874
+ .scaleBand()
1875
+ .domain(rows.map((r) => r[this.xKey]))
1876
+ .range([m.left, w - m.right])
1877
+ .padding(0.15);
1878
+ const y = d3
1879
+ .scaleLinear()
1880
+ .domain([0, d3.max(series, (s) => d3.max(s, (d) => d[1])) || 1])
1881
+ .nice()
1882
+ .range([h - m.bottom, m.top]);
1883
+ // Multi-series stacked bar: categorical palette in order. If the chart
1884
+ // has more series than palette colours, switch to sequential shades of
1885
+ // Dark blue to keep adjacent series distinguishable.
1886
+ const useSequential = this.yKeys.length > this.palette.categorical.length;
1887
+ const palette = this.palette;
1888
+ const color = useSequential
1889
+ ? (key) => shadeSequential(palette, this.yKeys.indexOf(key), this.yKeys.length)
1890
+ : d3.scaleOrdinal().domain(this.yKeys).range(palette.categorical);
1891
+
1892
+ const xAxis = svg
1893
+ .append('g')
1894
+ .attr('transform', `translate(0,${ h - m.bottom })`)
1895
+ .call(d3.axisBottom(x));
1896
+ svg.append('g').attr('transform', `translate(${ m.left },0)`).call(d3.axisLeft(y));
1897
+ this.rotateXLabels(xAxis, rotation);
1898
+
1899
+ const stackRects = svg
1900
+ .append('g')
1901
+ .selectAll('g')
1902
+ .data(series)
1903
+ .join('g')
1904
+ .attr('fill', (s) => this.fillFor(svg, this.yKeys.indexOf(s.key), color(s.key)))
1905
+ .attr('data-bdga-series', (s) => s.key)
1906
+ .selectAll('rect')
1907
+ .data((s) => s)
1908
+ .join('rect')
1909
+ .attr('x', (d) => x(d.data[this.xKey]))
1910
+ .attr('y', (d) => y(d[1]))
1911
+ .attr('height', (d) => y(d[0]) - y(d[1]))
1912
+ .attr('width', x.bandwidth());
1913
+
1914
+ // Register one navigable group per visible series. The series key is on
1915
+ // the parent <g> (data-bdga-series); each rect's datum carries the row in
1916
+ // d.data, so the un-stacked value is d.data[key].
1917
+ const byKey = new Map();
1918
+ stackRects.nodes().forEach((el) => {
1919
+ const sKey = el.parentNode.getAttribute('data-bdga-series');
1920
+ const d = d3.select(el).datum();
1921
+ if (!byKey.has(sKey)) byKey.set(sKey, []);
1922
+ byKey.get(sKey).push({ el, xVal: d.data[this.xKey], value: d.data[sKey] });
1923
+ });
1924
+ // Register top-to-bottom so the arrow-key direction matches the stack:
1925
+ // d3.stack puts the first key at the BOTTOM, so the last visible key is
1926
+ // the topmost band. Registering it as group 0 means ArrowUp (g-1) moves
1927
+ // to the band that is visually higher, ArrowDown to the one below.
1928
+ this.visibleKeys()
1929
+ .slice()
1930
+ .reverse()
1931
+ .forEach((key) => {
1932
+ if (byKey.has(key)) this.addPoints(key, byKey.get(key));
1933
+ });
1934
+
1935
+ this.drawAxisLabels(svg, w, h, m);
1936
+ }
1937
+
1938
+ /**
1939
+ * Line chart. Single-series stays one line in the primary colour; with two
1940
+ * or more y_keys it becomes a multi-series line chart where each series has
1941
+ * its own colour, a distinct marker shape (a colour-blind-safe redundant
1942
+ * cue), and a direct end-of-line label so the chart is readable without the
1943
+ * legend (UK Gov: "label lines directly"). Each series registers as its own
1944
+ * keyboard-nav group, so Up/Down move between series.
1945
+ */
1946
+ drawLine(rows) {
1947
+ const d3 = window.d3;
1948
+ const keys = this.visibleKeys();
1949
+ const multi = this.yKeys.length > 1;
1950
+ const dims = this.dims(rows);
1951
+ const { w, h, rotation } = dims;
1952
+ const m = Object.assign({}, dims.m);
1953
+ // Reserve right-margin room for the end-of-line labels when multi-series.
1954
+ if (multi) {
1955
+ const longest = keys.reduce((a, k) => Math.max(a, String(k).length), 0);
1956
+ m.right = Math.max(m.right, Math.min(160, 16 + longest * 7));
1957
+ }
1958
+ const svg = this.svgRoot(w, h);
1959
+
1960
+ const x = d3
1961
+ .scalePoint()
1962
+ .domain(rows.map((r) => r[this.xKey]))
1963
+ .range([m.left, w - m.right]);
1964
+ const yMax = d3.max(rows, (r) => d3.max(keys, (k) => +r[k] || 0)) || 1;
1965
+ const y = d3.scaleLinear().domain([0, yMax]).nice().range([h - m.bottom, m.top]);
1966
+
1967
+ const xAxis = svg
1968
+ .append('g')
1969
+ .attr('transform', `translate(0,${ h - m.bottom })`)
1970
+ .call(d3.axisBottom(x));
1971
+ svg.append('g').attr('transform', `translate(${ m.left },0)`).call(d3.axisLeft(y));
1972
+ this.rotateXLabels(xAxis, rotation);
1973
+
1974
+ const palette = this.palette;
1975
+ const SYMBOLS = [
1976
+ d3.symbolCircle, d3.symbolSquare, d3.symbolTriangle,
1977
+ d3.symbolDiamond, d3.symbolStar, d3.symbolCross, d3.symbolWye,
1978
+ ];
1979
+ // Colour and marker shape key off the ORIGINAL series index so a series
1980
+ // keeps both when others are toggled off.
1981
+ const idxOf = (key) => this.yKeys.indexOf(key);
1982
+ const colorFor = (key) => {
1983
+ if (!multi) return palette.single;
1984
+ const i = idxOf(key);
1985
+ return i < palette.categorical.length ? palette.categorical[i] : shadeSequential(palette, i, this.yKeys.length);
1986
+ };
1987
+ const symbolFor = (key) => d3.symbol().type(SYMBOLS[idxOf(key) % SYMBOLS.length]).size(70)();
1988
+
1989
+ keys.forEach((key) => {
1990
+ const color = colorFor(key);
1991
+ const g = svg.append('g').attr('data-bdga-series', key);
1992
+ const lineGen = d3.line().x((d) => x(d[this.xKey])).y((d) => y(+d[key] || 0));
1993
+ g.append('path')
1994
+ .attr('class', 'bdga-chart__line')
1995
+ .datum(rows)
1996
+ .attr('fill', 'none')
1997
+ .attr('stroke', color)
1998
+ .attr('stroke-width', 2)
1999
+ .attr('d', lineGen);
2000
+ const markers = g
2001
+ .selectAll('path.bdga-chart__line-marker')
2002
+ .data(rows)
2003
+ .join('path')
2004
+ .attr('class', 'bdga-chart__line-marker')
2005
+ .attr('transform', (d) => `translate(${x(d[this.xKey])},${y(+d[key] || 0)})`)
2006
+ .attr('d', symbolFor(key))
2007
+ .attr('fill', color);
2008
+
2009
+ // Direct end-of-line label (multi-series only); single-series is named
2010
+ // by the Y axis already.
2011
+ if (multi && rows.length) {
2012
+ const last = rows[rows.length - 1];
2013
+ g.append('text')
2014
+ .attr('class', 'bdga-chart__line-label')
2015
+ .attr('x', x(last[this.xKey]) + 6)
2016
+ .attr('y', y(+last[key] || 0))
2017
+ .attr('dy', '0.32em')
2018
+ .attr('fill', color)
2019
+ .text(key);
2020
+ }
2021
+
2022
+ this.addPoints(
2023
+ multi ? key : (this.yLabel || key),
2024
+ markers.nodes().map((el) => {
2025
+ const d = d3.select(el).datum();
2026
+ return { el, xVal: d[this.xKey], value: +d[key] || 0 };
2027
+ })
2028
+ );
2029
+ });
2030
+
2031
+ this.drawAxisLabels(svg, w, h, m);
2032
+ }
2033
+
2034
+ drawPie(rows) {
2035
+ const d3 = window.d3;
2036
+ const { w, h } = this.dims(null);
2037
+ const svg = this.svgRoot(w, h);
2038
+ const yKey = this.yKeys[0];
2039
+ // Reserve an outer ring for the direct slice labels (so each slice is
2040
+ // identifiable without the legend); shrink the slice radius to fit them.
2041
+ const r = Math.max(48, Math.min(w, h) / 2 - 72);
2042
+
2043
+ const g = svg.append('g').attr('transform', `translate(${ w / 2 },${ h / 2 })`);
2044
+ // Pie slices are categorical: colour by ORIGINAL slice index (over the
2045
+ // full row set) so a slice keeps its colour when others are toggled off.
2046
+ // The pie layout itself runs over only the visible rows so the remaining
2047
+ // slices re-fill the circle.
2048
+ const palette = this.palette;
2049
+ const total = rows.length;
2050
+ const colorByKey = new Map();
2051
+ const idxByKey = new Map();
2052
+ rows.forEach((d, i) => {
2053
+ const k = String(d[this.xKey]);
2054
+ idxByKey.set(k, i);
2055
+ colorByKey.set(
2056
+ k,
2057
+ total <= palette.categorical.length ? palette.categorical[i] : shadeSequential(palette, i, total)
2058
+ );
2059
+ });
2060
+ const visible = rows.filter((d) => !this.hidden.has(String(d[this.xKey])));
2061
+ const sum = visible.reduce((a, d) => a + (Number(d[yKey]) || 0), 0) || 1;
2062
+ const pie = d3.pie().value((d) => d[yKey]);
2063
+ const arc = d3.arc().innerRadius(0).outerRadius(r);
2064
+ const arcs = pie(visible);
2065
+
2066
+ const slices = g
2067
+ .selectAll('path.bdga-chart__pie-slice')
2068
+ .data(arcs)
2069
+ .join('path')
2070
+ .attr('class', 'bdga-chart__pie-slice')
2071
+ .attr('d', arc)
2072
+ .attr('data-bdga-series', (d) => String(d.data[this.xKey]))
2073
+ .attr('fill', (d) => {
2074
+ const k = String(d.data[this.xKey]);
2075
+ return this.fillFor(svg, idxByKey.get(k) || 0, colorByKey.get(k) || palette.single);
2076
+ })
2077
+ .attr('stroke', 'var(--ct-color-background, #fff)')
2078
+ .attr('stroke-width', 2);
2079
+
2080
+ // Direct slice labels with leader lines, outside the pie (Carbon callout
2081
+ // style), so the chart reads without the legend. Decorative for AT - the
2082
+ // slice path carries the accessible label - so the label group is hidden.
2083
+ const labelArc = d3.arc().innerRadius(r + 6).outerRadius(r + 6);
2084
+ const mid = (d) => d.startAngle + (d.endAngle - d.startAngle) / 2;
2085
+ const labels = g.append('g').attr('class', 'bdga-chart__pie-labels').attr('aria-hidden', 'true');
2086
+ arcs.forEach((d) => {
2087
+ const right = mid(d) < Math.PI;
2088
+ const elbow = labelArc.centroid(d);
2089
+ const end = [(r + 28) * (right ? 1 : -1), elbow[1]];
2090
+ labels
2091
+ .append('polyline')
2092
+ .attr('class', 'bdga-chart__pie-leader')
2093
+ .attr('points', [arc.centroid(d), elbow, end].map((p) => p.join(',')).join(' '));
2094
+ const pct = Math.round(((Number(d.data[yKey]) || 0) / sum) * 100);
2095
+ labels
2096
+ .append('text')
2097
+ .attr('class', 'bdga-chart__pie-label')
2098
+ .attr('x', end[0] + (right ? 4 : -4))
2099
+ .attr('y', end[1])
2100
+ .attr('dy', '0.32em')
2101
+ .attr('text-anchor', right ? 'start' : 'end')
2102
+ .text(`${d.data[this.xKey]} (${pct}%)`);
2103
+ });
2104
+
2105
+ // Slices are one navigable group; the label carries the share of the
2106
+ // whole so a screen-reader user gets the same insight a sighted user
2107
+ // reads off the wedge size.
2108
+ this.addPoints(
2109
+ null,
2110
+ slices.nodes().map((el) => {
2111
+ const d = d3.select(el).datum();
2112
+ const pct = Math.round(((Number(d.data[yKey]) || 0) / sum) * 100);
2113
+ return {
2114
+ el,
2115
+ label: Drupal.t('@x: @v, @p% of total', {
2116
+ '@x': d.data[this.xKey],
2117
+ '@v': this.formatValue(d.data[yKey]),
2118
+ '@p': pct,
2119
+ }),
2120
+ };
2121
+ })
2122
+ );
2123
+ }
2124
+
2125
+ /**
2126
+ * Lollipop chart - one stem-and-dot per category. Reuses drawLine's
2127
+ * scalePoint + circle pattern but skips the connecting path. Categories
2128
+ * are coloured by the categorical palette when color_by:category is set
2129
+ * (e.g. tier colouring for the MDPR per-project view); otherwise every
2130
+ * dot is the single primary colour.
2131
+ *
2132
+ * Optional median reference line driven by this.medianValue (computed
2133
+ * server-side in chart_postprocess.inc).
2134
+ */
2135
+ drawLollipop(rows) {
2136
+ const d3 = window.d3;
2137
+ const { w, h, m, rotation } = this.dims(rows);
2138
+ const svg = this.svgRoot(w, h);
2139
+ const yKey = this.yKeys[0];
2140
+
2141
+ const x = d3
2142
+ .scalePoint()
2143
+ .domain(rows.map((r) => r[this.xKey]))
2144
+ .range([m.left, w - m.right])
2145
+ .padding(0.5);
2146
+ const yMax = d3.max(rows, (r) => r[yKey]) || 1;
2147
+ const y = d3
2148
+ .scaleLinear()
2149
+ .domain([0, yMax])
2150
+ .nice()
2151
+ .range([h - m.bottom, m.top]);
2152
+
2153
+ const xAxis = svg
2154
+ .append('g')
2155
+ .attr('transform', `translate(0,${ h - m.bottom })`)
2156
+ .call(d3.axisBottom(x));
2157
+ svg.append('g').attr('transform', `translate(${ m.left },0)`).call(d3.axisLeft(y));
2158
+ this.rotateXLabels(xAxis, rotation);
2159
+
2160
+ // For lollipop the category column is conventionally a non-yKey label
2161
+ // such as "Tier"; we use it when color_by:category is enabled. With
2162
+ // no category column the chart falls back to the single primary
2163
+ // colour, which matches the MDPR figure caption "coloured by tier".
2164
+ const palette = this.palette;
2165
+ const categoryKey = (rows[0] && Object.keys(rows[0]).find((k) =>
2166
+ k !== this.xKey && k !== yKey && typeof rows[0][k] === 'string'
2167
+ )) || null;
2168
+ const categories = categoryKey
2169
+ ? Array.from(new Set(rows.map((r) => r[categoryKey])))
2170
+ : [];
2171
+ const categoryColor = categoryKey
2172
+ ? d3.scaleOrdinal().domain(categories).range(palette.categorical)
2173
+ : null;
2174
+ const dotColor = (d) => {
2175
+ if (this.colorBy === 'category' && categoryKey) {
2176
+ return categoryColor(d[categoryKey]);
2177
+ }
2178
+ return palette.single;
2179
+ };
2180
+
2181
+ const stems = svg.append('g').attr('class', 'bdga-chart__lollipop-stems');
2182
+ stems
2183
+ .selectAll('line')
2184
+ .data(rows)
2185
+ .join('line')
2186
+ .attr('class', 'bdga-chart__lollipop-stem')
2187
+ .attr('x1', (d) => x(d[this.xKey]))
2188
+ .attr('x2', (d) => x(d[this.xKey]))
2189
+ .attr('y1', y(0))
2190
+ .attr('y2', (d) => y(d[yKey]));
2191
+
2192
+ const dots = svg
2193
+ .append('g')
2194
+ .selectAll('circle')
2195
+ .data(rows)
2196
+ .join('circle')
2197
+ .attr('class', 'bdga-chart__lollipop-dot')
2198
+ .attr('cx', (d) => x(d[this.xKey]))
2199
+ .attr('cy', (d) => y(d[yKey]))
2200
+ .attr('r', 4)
2201
+ .attr('fill', dotColor);
2202
+ dots.append('title').text((d) => `${d[this.xKey] }: ${ d[yKey]}`);
2203
+ this.addPoints(
2204
+ this.yLabel || yKey,
2205
+ dots.nodes().map((el) => {
2206
+ const d = d3.select(el).datum();
2207
+ return { el, xVal: d[this.xKey], value: d[yKey] };
2208
+ })
2209
+ );
2210
+
2211
+ // Median reference line, drawn last so it sits above the stems.
2212
+ if (this.medianValue !== null && this.medianValue > 0) {
2213
+ const yPos = y(this.medianValue);
2214
+ svg
2215
+ .append('line')
2216
+ .attr('class', 'bdga-chart__lollipop-median')
2217
+ .attr('x1', m.left)
2218
+ .attr('x2', w - m.right)
2219
+ .attr('y1', yPos)
2220
+ .attr('y2', yPos);
2221
+ svg
2222
+ .append('text')
2223
+ .attr('class', 'bdga-chart__lollipop-median-label')
2224
+ .attr('x', w - m.right)
2225
+ .attr('y', yPos - 4)
2226
+ .attr('text-anchor', 'end')
2227
+ .text(Drupal.t('Median @v', { '@v': this.medianValue.toLocaleString() }));
2228
+ }
2229
+
2230
+ this.drawAxisLabels(svg, w, h, m);
2231
+ }
2232
+
2233
+ /**
2234
+ * Truncate the tick labels on a (vertical) axis group to fit a pixel
2235
+ * budget, keeping the full text in a <title> child for screen-reader and
2236
+ * hover users. Used by the Cleveland plot where category names (e.g. long
2237
+ * portfolio titles) would otherwise overrun the left margin.
2238
+ */
2239
+ truncateTickLabels(axisSel, pxBudget) {
2240
+ const maxChars = Math.max(6, Math.floor(pxBudget / 6.5));
2241
+ axisSel.selectAll('g.tick text').nodes().forEach((t) => {
2242
+ const full = t.textContent;
2243
+ if (full.length > maxChars) {
2244
+ t.textContent = `${full.slice(0, maxChars - 1)}…`;
2245
+ const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
2246
+ title.textContent = full;
2247
+ t.parentNode.appendChild(title);
2248
+ }
2249
+ });
2250
+ }
2251
+
2252
+ /**
2253
+ * Cleveland dot plot - one row per category with two dots (the two
2254
+ * y-series) joined by a connector, so the reader scans year-on-year change
2255
+ * down the column. Categories run down the Y axis and the value runs along
2256
+ * the X axis; the horizontal layout keeps long category labels readable and
2257
+ * the chart usable on narrow screens, where a 16-category grouped bar would
2258
+ * be unreadable. Needs exactly two y_keys. The connecting line carries the
2259
+ * magnitude of change without a separate annotation.
2260
+ */
2261
+ drawCleveland(rows) {
2262
+ const d3 = window.d3;
2263
+ const palette = this.palette;
2264
+ const keys = (this.yKeys || []).slice(0, 2);
2265
+ if (keys.length < 2) return this.fail('Cleveland dot plot needs two y_keys');
2266
+ const w = this.canvas.clientWidth || 640;
2267
+
2268
+ // Reserve left room for the category labels - up to ~38% of the canvas,
2269
+ // truncating longer names (full text stays in the dot titles + the data
2270
+ // table). Narrow containers get a smaller budget so the plot keeps a
2271
+ // usable value axis.
2272
+ const labels = rows.map((r) => String(r[this.xKey] != null ? r[this.xKey] : ''));
2273
+ const longest = labels.reduce((a, s) => Math.max(a, s.length), 0);
2274
+ const labelBudget = Math.min(Math.round(w * 0.38), Math.max(72, Math.round(longest * 6.5)));
2275
+ const m = { top: 44, right: 28, bottom: 44, left: labelBudget + 12 };
2276
+
2277
+ // Height grows with the number of categories so rows never crowd.
2278
+ const rowH = 26;
2279
+ const h = m.top + m.bottom + Math.max(1, rows.length) * rowH;
2280
+ const svg = this.svgRoot(w, h);
2281
+
2282
+ const y = d3
2283
+ .scaleBand()
2284
+ .domain(labels)
2285
+ .range([m.top, h - m.bottom])
2286
+ .padding(0.45);
2287
+ const xMax = d3.max(rows, (r) => Math.max(...keys.map((k) => Number(r[k]) || 0))) || 1;
2288
+ const x = d3
2289
+ .scaleLinear()
2290
+ .domain([0, xMax])
2291
+ .nice()
2292
+ .range([m.left, w - m.right]);
2293
+
2294
+ svg
2295
+ .append('g')
2296
+ .attr('transform', `translate(0,${h - m.bottom})`)
2297
+ .call(d3.axisBottom(x).ticks(Math.min(8, xMax)).tickFormat(d3.format('d')));
2298
+ const yAxis = svg
2299
+ .append('g')
2300
+ .attr('transform', `translate(${m.left},0)`)
2301
+ .call(d3.axisLeft(y));
2302
+ this.truncateTickLabels(yAxis, labelBudget);
2303
+
2304
+ const colorA = palette.categorical[0];
2305
+ const colorB = palette.categorical[1];
2306
+ const cy = (d) => y(String(d[this.xKey] != null ? d[this.xKey] : '')) + y.bandwidth() / 2;
2307
+
2308
+ const rowG = svg
2309
+ .append('g')
2310
+ .selectAll('g')
2311
+ .data(rows)
2312
+ .join('g')
2313
+ .attr('class', 'bdga-chart__cleveland-row')
2314
+ .attr('tabindex', '0')
2315
+ .attr('role', 'img')
2316
+ .attr('aria-label', (d) => `${d[this.xKey]}: ${keys[0]} ${d[keys[0]]}, ${keys[1]} ${d[keys[1]]}`);
2317
+
2318
+ rowG
2319
+ .append('line')
2320
+ .attr('class', 'bdga-chart__cleveland-connector')
2321
+ .attr('x1', (d) => x(Number(d[keys[0]]) || 0))
2322
+ .attr('x2', (d) => x(Number(d[keys[1]]) || 0))
2323
+ .attr('y1', cy)
2324
+ .attr('y2', cy);
2325
+
2326
+ keys.forEach((k, i) => {
2327
+ rowG
2328
+ .append('circle')
2329
+ .attr('class', `bdga-chart__cleveland-dot bdga-chart__cleveland-dot--${i + 1}`)
2330
+ .attr('cx', (d) => x(Number(d[k]) || 0))
2331
+ .attr('cy', cy)
2332
+ .attr('r', 5)
2333
+ .attr('fill', i === 0 ? colorA : colorB)
2334
+ .append('title')
2335
+ .text((d) => `${d[this.xKey]} - ${k}: ${d[k]}`);
2336
+ });
2337
+
2338
+ // Inline legend: one swatch per series, above the plot area.
2339
+ const legend = svg
2340
+ .append('g')
2341
+ .attr('class', 'bdga-chart__cleveland-legend')
2342
+ .attr('transform', `translate(${m.left},22)`);
2343
+ keys.forEach((k, i) => {
2344
+ const g = legend.append('g').attr('transform', `translate(${i * 96},0)`);
2345
+ g.append('circle').attr('r', 5).attr('cx', 5).attr('cy', -4).attr('fill', i === 0 ? colorA : colorB);
2346
+ g.append('text').attr('class', 'bdga-chart__cleveland-legend-label').attr('x', 16).attr('y', 0).text(k);
2347
+ });
2348
+
2349
+ this.drawAxisLabels(svg, w, h, m);
2350
+
2351
+ // The row groups are already focusable (tabindex=0) and labelled, but
2352
+ // draw() left the canvas aria-hidden, so AT could not reach them. Expose
2353
+ // the plot the same way the point-nav charts do. Cleveland uses one tab
2354
+ // stop per row (no roving arrow model), so the hint says "Tab".
2355
+ this.exposePlot(Drupal.t('Tab to each row for its values.'));
2356
+ }
2357
+
2358
+ /**
2359
+ * Internal: render a sankey diagram for the given alignment.
2360
+ *
2361
+ * Colour model:
2362
+ * - When every node id is prefixed (e.g. "2025: High", "From: X"),
2363
+ * nodes are grouped by the suffix and each unique group is assigned
2364
+ * a colour from palette.sequential. The same rating in every year
2365
+ * column then renders in the same shade, matching the MDPR Fig 18
2366
+ * style.
2367
+ * - Without prefixes the renderer falls back to per-index categorical
2368
+ * colouring (rainbow), which is the right answer for ad-hoc graphs
2369
+ * where node ids carry no ordinal structure.
2370
+ * - Link strokes inherit the source node's group colour. The CSS rule
2371
+ * must NOT declare a stroke colour for .bdga-chart__sankey-link, or
2372
+ * it would beat this presentation attribute on specificity and
2373
+ * flatten every link to one hue.
2374
+ *
2375
+ * Label model:
2376
+ * - One column header per d3-sankey depth, drawn above the column
2377
+ * using the shared prefix (e.g. "2025"). Skipped if column nodes
2378
+ * don't share a prefix.
2379
+ * - Node labels show only the suffix (the rating), with text
2380
+ * anchored outside the chart for the leftmost / rightmost columns
2381
+ * and above the rect for middle columns to avoid overlapping the
2382
+ * link bundles.
2383
+ *
2384
+ * d3-sankey mutates its input nodes/links in place; we shallow-clone so
2385
+ * a re-draw on resize doesn't double-mutate the originals.
2386
+ */
2387
+ drawSankeyInternal(alignFn) {
2388
+ const d3 = window.d3;
2389
+ const w = this.canvas.clientWidth || 640;
2390
+ const h = Math.min(Math.max(w * 0.55, 320), 520);
2391
+ // Side margins reserve room for the leftmost / rightmost node labels and
2392
+ // the column headers. They are CSS-var knobs switched by the @container
2393
+ // queries in chart.scss: ~140px on wide containers, tightened on narrow
2394
+ // ones (mobile, sidebars) so the columns still fit. Authors can override.
2395
+ const c = this.canvas;
2396
+ let side = cssNum(c, '--bdga-chart-sankey-margin-x', 140);
2397
+ // Hard floor independent of the knobs: never let the two columns overlap.
2398
+ // Guarantees a positive plot area even if an author sets a large margin
2399
+ // on a very narrow embed, or the container query hasn't matched yet.
2400
+ const MIN_PLOT = 80;
2401
+ if (w - side * 2 < MIN_PLOT) {
2402
+ side = Math.max(8, Math.floor((w - MIN_PLOT) / 2));
2403
+ }
2404
+ const m = {
2405
+ top: cssNum(c, '--bdga-chart-sankey-margin-top', 60),
2406
+ right: side,
2407
+ bottom: cssNum(c, '--bdga-chart-sankey-margin-bottom', 10),
2408
+ left: side,
2409
+ };
2410
+ // 'outside' places the edge-column labels left of / right of their rects
2411
+ // (needs the wide side margins above); 'stacked' places every label above
2412
+ // its rect so narrow containers don't clip the edges. Switched by the
2413
+ // @container queries in chart.scss.
2414
+ const stackedLabels = cssVar(c, '--bdga-chart-sankey-label-mode', 'outside') !== 'outside';
2415
+ const svg = this.svgRoot(w, h);
2416
+
2417
+ // Split node ids into {prefix, label}. For an id "2025: High" the
2418
+ // prefix is "2025" and the label is "High"; for an id with no ": "
2419
+ // separator the prefix is "" and the label is the whole id. We pre-
2420
+ // compute this on the un-laid-out nodes so the d3-sankey nodeSort
2421
+ // and nodeAlign callbacks (which fire during layout) can see labels.
2422
+ const split = (id) => {
2423
+ const idx = id.indexOf(': ');
2424
+ return idx >= 0 ? { prefix: id.slice(0, idx), label: id.slice(idx + 2) } : { prefix: '', label: id };
2425
+ };
2426
+ const meta = new Map();
2427
+ this.nodes.forEach((n) => meta.set(n.id, split(n.id)));
2428
+ const allPrefixed = this.nodes.every((n) => meta.get(n.id).prefix !== '');
2429
+
2430
+ // Stage index per prefix. Two sources, in order of trust:
2431
+ // 1. node.stage attached by buildSankeyFromCascadeRows. The
2432
+ // cascade builder knows the column order from stageCols, so
2433
+ // this is the canonical answer for URL-mode flow charts and
2434
+ // it's independent of which row happens to be first non-null.
2435
+ // 2. Encounter order on input nodes. For JSON-mode authors who
2436
+ // hand-write a node array in stage order, this still produces
2437
+ // the right layout.
2438
+ const stageIndex = new Map();
2439
+ this.nodes.forEach((n) => {
2440
+ const p = meta.get(n.id).prefix;
2441
+ if (!p) return;
2442
+ if (typeof n.stage === 'number' && !stageIndex.has(p)) {
2443
+ stageIndex.set(p, n.stage);
2444
+ }
2445
+ });
2446
+ this.nodes.forEach((n) => {
2447
+ const p = meta.get(n.id).prefix;
2448
+ if (p && !stageIndex.has(p)) {
2449
+ stageIndex.set(p, stageIndex.size);
2450
+ }
2451
+ });
2452
+
2453
+ // Custom alignment: when every node has a known prefix, force each
2454
+ // into its stage's column regardless of upstream connectivity. This
2455
+ // fixes the case where a row like {y1: null, y2: 'X', y3: 'Y'}
2456
+ // produces a y2 node with no incoming link - d3-sankey's default
2457
+ // justify alignment would demote it to column 0 and mix prefixes,
2458
+ // which blanks out the column header logic below. Fall back to the
2459
+ // requested alignment (justify by default) for unprefixed graphs.
2460
+ const fallbackAlign = alignFn || d3.sankeyJustify;
2461
+ const customAlign = allPrefixed && stageIndex.size >= 2
2462
+ ? (node, n) => {
2463
+ if (typeof node.stage === 'number') return node.stage;
2464
+ const p = meta.get(node.id).prefix;
2465
+ const i = stageIndex.get(p);
2466
+ return i !== undefined ? i : fallbackAlign(node, n);
2467
+ }
2468
+ : fallbackAlign;
2469
+
2470
+ const sankeyGen = d3
2471
+ .sankey()
2472
+ .nodeId((d) => d.id)
2473
+ .nodeAlign(customAlign)
2474
+ .nodeWidth(14)
2475
+ .nodePadding(12)
2476
+ .extent([
2477
+ [m.left, m.top],
2478
+ [w - m.right, h - m.bottom],
2479
+ ])
2480
+ // Vertical order within each column: rank known ordinals (High at
2481
+ // the top, Not Reported at the bottom) and leave unknown labels
2482
+ // in encounter order. Passing null would disable d3-sankey's own
2483
+ // crossing-minimisation entirely; this comparator only reshuffles
2484
+ // among the ranked labels.
2485
+ .nodeSort((a, b) => compareByRank(meta.get(a.id).label, meta.get(b.id).label));
2486
+
2487
+ const nodes = this.nodes.map((n) => Object.assign({}, n));
2488
+ const links = this.links.map((l) => Object.assign({}, l));
2489
+ const graph = sankeyGen({ nodes, links });
2490
+
2491
+ const palette = this.palette;
2492
+ // Muted neutral for "Not reported" / "Unable to rate" so they fall
2493
+ // off the rank ramp visually. Comes from a sankey-specific CSS
2494
+ // variable so themes can tune it without touching the JS.
2495
+ const mutedColour = cssVar(this.root, '--bdga-chart-sankey-muted', '#a8a8a8');
2496
+ let colourForNode;
2497
+ if (allPrefixed) {
2498
+ // Collect the unique suffixes in encounter order, then sort them
2499
+ // by ordinal rank so known categories (High..Not Reported) own
2500
+ // the head of the sequential ramp and pick up the darkest shades.
2501
+ // Unknown labels keep encounter order and fill the tail. Ranks
2502
+ // 5+ (Not reported / Unable to rate) are pulled out of the ramp
2503
+ // and rendered as muted grey, matching the MDPR Fig 18 treatment
2504
+ // where reporting gaps read as desaturated.
2505
+ const isMutedLabel = (label) => {
2506
+ const r = rankOf(label);
2507
+ return r !== null && r >= 5;
2508
+ };
2509
+ const seen = new Set();
2510
+ const encounterOrder = [];
2511
+ graph.nodes.forEach((n) => {
2512
+ const key = meta.get(n.id).label;
2513
+ if (!seen.has(key) && !isMutedLabel(key)) {
2514
+ seen.add(key);
2515
+ encounterOrder.push(key);
2516
+ }
2517
+ });
2518
+ const ordered = encounterOrder.slice().sort(compareByRank);
2519
+ const groupIndex = new Map();
2520
+ ordered.forEach((key, i) => groupIndex.set(key, i));
2521
+ const totalGroups = ordered.length;
2522
+ colourForNode = (n) => {
2523
+ const label = meta.get(n.id).label;
2524
+ if (isMutedLabel(label)) return mutedColour;
2525
+ const i = groupIndex.get(label);
2526
+ if (i === undefined) return palette.single;
2527
+ return i < palette.sequential.length
2528
+ ? palette.sequential[i]
2529
+ : shadeSequential(palette, i, totalGroups);
2530
+ };
2531
+ }
2532
+ else {
2533
+ colourForNode = (n, i) =>
2534
+ i < palette.categorical.length
2535
+ ? palette.categorical[i]
2536
+ : shadeSequential(palette, i, graph.nodes.length);
2537
+ }
2538
+
2539
+ const colorByNode = new Map();
2540
+ graph.nodes.forEach((n, i) => colorByNode.set(n.id, colourForNode(n, i)));
2541
+
2542
+ // Column headers: one per d.layer (the column index actually set
2543
+ // by nodeAlign), positioned above the column's first node. Skip
2544
+ // when nodes in the column don't share a prefix. Note that d.depth
2545
+ // is the topological longest-path distance from a source, which
2546
+ // is NOT the column for graphs with leading-null cascades; we
2547
+ // must read d.layer here or 2024 would land in the wrong slot.
2548
+ const headers = new Map();
2549
+ graph.nodes.forEach((n) => {
2550
+ const p = meta.get(n.id).prefix;
2551
+ if (!p) return;
2552
+ if (!headers.has(n.layer)) {
2553
+ headers.set(n.layer, { x: (n.x0 + n.x1) / 2, prefix: p });
2554
+ }
2555
+ else if (headers.get(n.layer).prefix !== p) {
2556
+ // Mixed prefixes in a column - leave the header off rather
2557
+ // than guess. Marker '' tells the render step to skip.
2558
+ headers.set(n.layer, { x: 0, prefix: '' });
2559
+ }
2560
+ });
2561
+ // Column headers and link bands are decorative for AT: the per-node
2562
+ // labels (added below) carry the meaning, so hide these to avoid noise.
2563
+ const headerGroup = svg.append('g').attr('aria-hidden', 'true');
2564
+ headers.forEach((meta_) => {
2565
+ if (!meta_.prefix) return;
2566
+ headerGroup
2567
+ .append('text')
2568
+ .attr('class', 'bdga-chart__sankey-column-header')
2569
+ .attr('x', meta_.x)
2570
+ .attr('y', m.top - 20)
2571
+ .attr('text-anchor', 'middle')
2572
+ .text(meta_.prefix);
2573
+ });
2574
+
2575
+ // Links underneath the node rects so they appear to plug in.
2576
+ const linkGroup = svg.append('g').attr('fill', 'none').attr('aria-hidden', 'true');
2577
+ linkGroup
2578
+ .selectAll('path')
2579
+ .data(graph.links)
2580
+ .join('path')
2581
+ .attr('class', 'bdga-chart__sankey-link')
2582
+ .attr('d', d3.sankeyLinkHorizontal())
2583
+ .attr('stroke', (d) => colorByNode.get(d.source.id) || palette.single)
2584
+ .attr('stroke-width', (d) => Math.max(1, d.width))
2585
+ .append('title')
2586
+ .text((d) => {
2587
+ let base = `${d.source.id } → ${ d.target.id }: ${ d.value}`;
2588
+ if (typeof d.budget === 'number') {
2589
+ base += ` ($${ d.budget.toFixed(2) }B)`;
2590
+ }
2591
+ return base;
2592
+ });
2593
+
2594
+ // Column count for placement decisions. d.layer is the column
2595
+ // index set by nodeAlign above; d.depth would be topology and
2596
+ // would mis-classify nodes whose chains started mid-cascade.
2597
+ const maxLayer = d3.max(graph.nodes, (n) => n.layer);
2598
+ const nodeGroup = svg
2599
+ .append('g')
2600
+ .selectAll('g')
2601
+ .data(graph.nodes)
2602
+ .join('g')
2603
+ .attr('class', 'bdga-chart__sankey-node');
2604
+
2605
+ nodeGroup
2606
+ .append('rect')
2607
+ .attr('x', (d) => d.x0)
2608
+ .attr('y', (d) => d.y0)
2609
+ .attr('width', (d) => Math.max(1, d.x1 - d.x0))
2610
+ .attr('height', (d) => Math.max(1, d.y1 - d.y0))
2611
+ .attr('fill', (d) => colorByNode.get(d.id) || palette.single)
2612
+ .append('title')
2613
+ .text((d) => `${d.name || d.id }: ${ d.value}`);
2614
+
2615
+ // Label placement, keyed off d.layer so it tracks the column index
2616
+ // actually used by the layout. Two modes (see stackedLabels above):
2617
+ // outside (wide containers):
2618
+ // layer 0 -> outside the rect on its left
2619
+ // layer === maxLayer -> outside the rect on its right
2620
+ // middle columns -> centred above the rect
2621
+ // stacked (narrow containers): every label sits above its rect, with
2622
+ // the edge columns anchored to their inner side so they read inward
2623
+ // and never spill past the canvas edge.
2624
+ // Multi-stage flows use the column header for the stage label, so
2625
+ // each node's own text is just its suffix (e.g. "Medium-High").
2626
+ const isEdge = (d) => d.layer === 0 || d.layer === maxLayer;
2627
+ nodeGroup
2628
+ .append('text')
2629
+ .attr('text-anchor', (d) => {
2630
+ if (stackedLabels) {
2631
+ if (d.layer === 0) return 'start';
2632
+ if (d.layer === maxLayer) return 'end';
2633
+ return 'middle';
2634
+ }
2635
+ if (d.layer === 0) return 'end';
2636
+ if (d.layer === maxLayer) return 'start';
2637
+ return 'middle';
2638
+ })
2639
+ .attr('x', (d) => {
2640
+ if (stackedLabels) {
2641
+ if (d.layer === 0) return d.x0;
2642
+ if (d.layer === maxLayer) return d.x1;
2643
+ return (d.x0 + d.x1) / 2;
2644
+ }
2645
+ if (d.layer === 0) return d.x0 - 6;
2646
+ if (d.layer === maxLayer) return d.x1 + 6;
2647
+ return (d.x0 + d.x1) / 2;
2648
+ })
2649
+ .attr('y', (d) => {
2650
+ if (!stackedLabels && isEdge(d)) return (d.y0 + d.y1) / 2;
2651
+ return d.y0 - 4;
2652
+ })
2653
+ .attr('dy', (d) => (!stackedLabels && isEdge(d) ? '0.35em' : '0'))
2654
+ .text((d) => meta.get(d.id).label);
2655
+
2656
+ // Keyboard navigation: each node group is a focusable, labelled point.
2657
+ // role="img" (set by addPoints) makes the group a leaf for AT, so its
2658
+ // rect / text / title are not announced separately; with the links and
2659
+ // headers aria-hidden, a screen reader hears one clear label per node.
2660
+ // Nodes are a single nav group - Left/Right and Home/End move through
2661
+ // them in layout order.
2662
+ this.addPoints(
2663
+ null,
2664
+ nodeGroup.nodes().map((el) => {
2665
+ const d = d3.select(el).datum();
2666
+ const incoming = (d.targetLinks || []).length;
2667
+ const outgoing = (d.sourceLinks || []).length;
2668
+ const parts = [];
2669
+ if (incoming) parts.push(Drupal.t('@n in', { '@n': incoming }));
2670
+ if (outgoing) parts.push(Drupal.t('@n out', { '@n': outgoing }));
2671
+ const flows = parts.length ? `. ${parts.join(', ')}` : '';
2672
+ return { el, label: `${d.name || d.id}: ${this.formatValue(d.value)}${flows}` };
2673
+ })
2674
+ );
2675
+ }
2676
+
2677
+ /**
2678
+ * Sankey - left-to-right flow diagram. Uses d3.sankeyJustify so the
2679
+ * leftmost column is anchored at x=0 and rightmost at x=width, which
2680
+ * matches the MDPR Figure 18 DCA flow layout.
2681
+ */
2682
+ drawSankey() {
2683
+ this.drawSankeyInternal(window.d3.sankeyJustify);
2684
+ }
2685
+
2686
+ /**
2687
+ * Flow - multi-stage alluvial diagram (e.g. 2024 -> 2025 -> 2026).
2688
+ * Same renderer as sankey; the data shape declares the staging via
2689
+ * node ordering, and the d3-sankey layout handles the rest. Kept as a
2690
+ * separate type so authors / templates can style it differently, but
2691
+ * the visual difference is currently only via CSS hooks.
2692
+ */
2693
+ drawFlow() {
2694
+ this.drawSankeyInternal(window.d3.sankeyJustify);
2695
+ }
2696
+ }
2697
+ })(window.Drupal, window.once);
2698
+
2699
+ // Static-page driver. Drupal core runs Drupal.attachBehaviors() after page
2700
+ // load + each AJAX swap; without it, the bdgaChart behaviour above is
2701
+ // registered but never attached. Run it on DOMContentLoaded and on every
2702
+ // DOM mutation so async-rendered story canvases still trigger. once() inside
2703
+ // the behaviour deduplicates, so repeated calls are cheap.
2704
+ (function () {
2705
+ 'use strict';
2706
+ if (typeof window.Drupal === 'undefined' || typeof window.Drupal.attachBehaviors === 'function') {
2707
+ return;
2708
+ }
2709
+ const attach = (context) => {
2710
+ Object.values(window.Drupal.behaviors).forEach((b) => {
2711
+ if (b && typeof b.attach === 'function') b.attach(context || document);
2712
+ });
2713
+ };
2714
+ window.Drupal.attachBehaviors = attach;
2715
+ const run = () => attach(document);
2716
+ if (document.readyState === 'loading') {
2717
+ document.addEventListener('DOMContentLoaded', run);
2718
+ } else {
2719
+ run();
2720
+ }
2721
+ new MutationObserver(run).observe(
2722
+ document.body || document.documentElement,
2723
+ { childList: true, subtree: true }
2724
+ );
2725
+ })();