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.