PageSourceSearch

https://umbc.edu/wp-content/themes/umbc-theme-2.0/umbc-blocks/charts-block/charts/charts-utils.js

js umbc.edu collected 2026-09-24 07:39:51 UTC 8,200 bytes, 218 lines download raw bytes

1/**
2 * UMBC Charts Block — Shared Utilities
3 *
4 * D3-based helpers (Chart.js dependency removed).
5 */
6
7// D3 is loaded as a global <script> dependency via wp_register_script.
8// ES modules parse before classic scripts execute, so we must read
9// window.d3 lazily (at call time) rather than at module parse time.
10function getD3() {
11    if (!window.d3) throw new Error('D3.js not loaded — check wp_register_script dependencies');
12    return window.d3;
13}
14
15// Shared registry — keyed by the <figure> container element
16export const chartInstances = new Map();
17
18/**
19 * Split long legend titles into multiple lines.
20 */
21export function wrapLegendTitle(text, maxLen = 30) {
22    if (!text || typeof text !== 'string') return text;
23    const regex = new RegExp(`(.{1,${maxLen}})(?:\\s|$)`, 'g');
24    const lines = text.match(regex);
25    return lines.map(line => line.trim());
26}
27
28/**
29 * Remove all chart-specific children but keep <h3> title and .chart-description.
30 */
31export function clearChartContents(chartEl) {
32    chartEl.querySelectorAll(
33        ':scope > *:not(h3):not(.chart-description):not(.chart-title):not(figcaption):not(script)'
34    ).forEach(node => node.remove());
35}
36
37/**
38 * Destroy any existing D3 SVG or leftover canvas inside the container.
39 */
40export function destroyExistingChart(container) {
41    const svg = container.querySelector('svg.umbc-chart-svg');
42    if (svg) svg.remove();
43
44    container.querySelectorAll('.chart-legend, .umbc-chart-tooltip').forEach(el => el.remove());
45    container.classList.remove('chart--legend-left', 'chart--legend-right');
46
47    if (chartInstances.has(container)) {
48        chartInstances.delete(container);
49    }
50}
51
52/**
53 * Format values based on dataset_type.
54 */
55export function formatChartValue(value, type) {
56    switch (type) {
57        case 'percentage':
58            return `${parseFloat(value).toFixed(1)}%`;
59        case 'decimal':
60            return parseFloat(value).toFixed(2);
61        case 'integer':
62            return parseInt(value, 10);
63        case 'comma_integer':
64            return parseInt(value, 10).toLocaleString();
65        case 'currency':
66            return `$${parseFloat(value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
67        default:
68            return value;
69    }
70}
71
72/* ------------------------------------------------------------------
73 *  createChartSvg — standard accessible SVG container
74 * ---------------------------------------------------------------- */
75/**
76 * Append an accessible <svg> to a container and return the d3 selection.
77 *
78 * @param {Element} container  — parent DOM element
79 * @param {number}  width      — outer width including margins
80 * @param {number}  height     — outer height including margins
81 * @param {string}  ariaLabel  — descriptive text for screen readers
82 * @returns {d3.Selection}     — the <svg> selection
83 */
84export function createChartSvg(container, width, height, ariaLabel) {
85    const d3 = getD3();
86    return d3.select(container)
87        .append('svg')
88        .attr('class', 'umbc-chart-svg')
89        .attr('width', width)
90        .attr('height', height)
91        .attr('role', 'img')
92        .attr('aria-label', ariaLabel || 'Chart')
93        .attr('focusable', 'false')
94        .append(function () {
95            // <title> inside SVG for richer screen-reader descriptions
96            const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
97            title.textContent = ariaLabel || 'Chart';
98            return title;
99        })
100        .select(function () { return this.parentNode; }); // return the <svg>, not <title>
101}
102
103/* ------------------------------------------------------------------
104 *  buildLegend — shared HTML legend (replaces Chart.js plugin legend)
105 * ---------------------------------------------------------------- */
106/**
107 * Build and append an HTML legend to a container.
108 *
109 * @param {Element} container — parent DOM element
110 * @param {Array}   items     — [{ label, color, strokeColor, value? }]
111 * @param {Object}  options   — { position:'top', style:'circle'|'rect', show:true }
112 * @returns {Element|null}    — the legend element or null if hidden
113 */
114export function buildLegend(container, items, options = {}) {
115    const { position = 'top', style = 'rect', show = true } = options;
116    if (!show || !items.length) return null;
117
118    const legend = document.createElement('div');
119    legend.className = 'chart-legend';
120    legend.setAttribute('aria-label', 'Chart legend');
121
122    // Position-specific layout
123    if (position === 'left' || position === 'right') {
124        legend.classList.add('chart-legend--vertical');
125    } else {
126        legend.classList.add('chart-legend--horizontal');
127    }
128
129    const ul = document.createElement('ul');
130    items.forEach(item => {
131        const li = document.createElement('li');
132
133        const swatch = document.createElement('span');
134        swatch.className = 'legend-swatch' + (style === 'circle' ? ' legend-swatch--circle' : '');
135        swatch.style.backgroundColor = item.color;
136        swatch.style.borderColor = item.strokeColor || item.color;
137        swatch.setAttribute('aria-hidden', 'true');
138        li.appendChild(swatch);
139
140        const text = document.createElement('span');
141        text.className = 'legend-label';
142        text.textContent = item.value !== undefined
143            ? `${item.label}: ${item.value}`
144            : item.label;
145        li.appendChild(text);
146
147        ul.appendChild(li);
148    });
149    legend.appendChild(ul);
150
151    // Apply side-by-side layout class to the container when legend is left/right
152    container.classList.toggle('chart--legend-left', position === 'left');
153    container.classList.toggle('chart--legend-right', position === 'right');
154
155    // Insert at the right position relative to the SVG
156    if (position === 'top' || position === 'left') {
157        const svg = container.querySelector('svg.umbc-chart-svg');
158        if (svg) {
159            container.insertBefore(legend, svg);
160        } else {
161            container.appendChild(legend);
162        }
163    } else {
164        container.appendChild(legend);
165    }
166
167    return legend;
168}
169
170/* ------------------------------------------------------------------
171 *  buildTooltip — shared hover tooltip
172 * ---------------------------------------------------------------- */
173/**
174 * Create a tooltip controller for a chart container.
175 *
176 * @param {Element} container — parent DOM element (should be position:relative)
177 * @returns {{ show(x, y, html), hide(), el: Element }}
178 */
179export function buildTooltip(container) {
180    // Ensure the container is positioned so the tooltip can be absolute
181    const pos = getComputedStyle(container).position;
182    if (pos === 'static') container.style.position = 'relative';
183
184    const tip = document.createElement('div');
185    tip.className = 'umbc-chart-tooltip';
186    tip.setAttribute('role', 'status');
187    tip.setAttribute('aria-live', 'polite');
188    container.appendChild(tip);
189
190    return {
191        el: tip,
192        show(x, y, html) {
193            tip.innerHTML = html;
194            // Make the tooltip measurable before clamping its position.
195            tip.classList.add('visible');
196            // The tooltip is centred on x via translateX(-50%); without
197            // clamping, slices near the container edge (e.g. a full-width
198            // donut on mobile) push half the box off-screen. Keep the whole
199            // box inside the container's horizontal bounds.
200            const margin = 4;
201            const cw = container.clientWidth;
202            const half = tip.offsetWidth / 2;
203            const clampedX = Math.max(half + margin, Math.min(x, cw - half - margin));
204            tip.style.left = clampedX + 'px';
205            tip.style.top = y + 'px';
206        },
207        hide() {
208            tip.classList.remove('visible');
209        }
210    };
211}
212
213/* ------------------------------------------------------------------
214 *  reducedMotion — honour prefers-reduced-motion
215 * ---------------------------------------------------------------- */
216export function prefersReducedMotion() {
217    return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
218}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.