@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.
- package/components/03-organisms/chart/chart.js +2725 -0
- package/components/03-organisms/chart/chart.scss +585 -0
- package/components/03-organisms/chart/chart.stories.js +498 -0
- package/components/03-organisms/chart/chart.twig +239 -0
- package/dist/civictheme.css +436 -0
- package/dist/civictheme.storybook.css +436 -0
- package/dist/civictheme.storybook.js +2728 -0
- package/package.json +5 -5
|
@@ -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
|
+
})();
|