1const SEGMENTS = [ 2 { key: "onsite", label: "Onsite", color: "#72c18f" }, 3 { key: "remote", label: "Remote", color: "#f5cf4e" }, 4 { key: "notSpecific", label: "Not Specific", color: "#62b4ea" }, 5]; 6 7const PALETTE = [ 8 "#4b7fd1", "#72c18f", "#f5cf4e", "#62b4ea", "#e48d33", 9 "#a259d9", "#e05c5c", "#38b2ac", "#f6a623", "#7b8fa6", 10 "#56c4a0", "#d4736a", "#6fa8dc", "#c4a35a", "#8e7dbf", 11]; 12 13const CHART_CONFIG = { 14 half: { 15 centerX: 180, 16 centerY: 180, 17 endAngle: 90, 18 gapPx: 4, 19 radius: 124, 20 startAngle: -90, 21 totalClassName: "donut-total donut-total--half", 22 viewBox: "0 0 360 220", 23 }, 24 full: { 25 centerX: 180, 26 centerY: 180, 27 endAngle: 270, 28 gapPx: 2, 29 radius: 124, 30 startAngle: -90, 31 totalClassName: "donut-total donut-total--full", 32 viewBox: "0 0 360 360", 33 }, 34}; 35 36const getValue = (root, attributeName) => { 37 const raw = Number.parseInt(root.getAttribute(attributeName) || "0", 10); 38 return Number.isFinite(raw) ? Math.max(0, raw) : 0; 39}; 40 41const normaliseItem = (item, index) => ({ 42 color: item?.color || SEGMENTS[index % SEGMENTS.length]?.color || "#62b4ea", 43 label: item?.label || `Item ${index + 1}`, 44 value: Number.isFinite(Number(item?.value)) ? Math.max(0, Number(item.value)) : 0, 45}); 46 47const escapeHtml = (value) => 48 String(value) 49 .replace(/&/g, "&") 50 .replace(/</g, "<") 51 .replace(/>/g, ">") 52 .replace(/\"/g, """) 53 .replace(/'/g, "'"); 54 55const parseJsonConfig = (root) => { 56 const dataNode = root.querySelector(".gdm-chart__data"); 57 if (!dataNode) return null; 58 59 try { 60 return JSON.parse(dataNode.textContent || "{}"); 61 } catch (error) { 62 console.error("Invalid donut chart config", error); 63 return null; 64 } 65}; 66 67const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => { 68 const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180; 69 70 return { 71 x: centerX + radius * Math.cos(angleInRadians), 72 y: centerY + radius * Math.sin(angleInRadians), 73 }; 74}; 75 76const describeArc = (centerX, centerY, radius, startAngle, endAngle) => { 77 const start = polarToCartesian(centerX, centerY, radius, endAngle); 78 const end = polarToCartesian(centerX, centerY, radius, startAngle); 79 const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"; 80 81 return [ 82 "M", 83 start.x, 84 start.y, 85 "A", 86 radius, 87 radius, 88 0, 89 largeArcFlag, 90 0, 91 end.x, 92 end.y, 93 ].join(" "); 94}; 95 96const buildArcMarkup = (items, variantName, totalLabel, total) => { 97 const variant = CHART_CONFIG[variantName] || CHART_CONFIG.half; 98 const totalAngle = variant.endAngle - variant.startAngle; 99 const gapAngle = (variant.gapPx / variant.radius) * (180 / Math.PI); 100 const chartTotal = items.reduce((sum, item) => sum + item.value, 0) || 1; 101 const renderAllBoundaries = variantName === "full"; 102 103 let currentAngle = variant.startAngle; 104 const paths = items 105 .filter((item) => item.value > 0) 106 .map((item, index, visibleItems) => { 107 const segmentAngle = (item.value / chartTotal) * totalAngle; 108 const hasLeadingGap = renderAllBoundaries || index > 0; 109 const hasTrailingGap = renderAllBoundaries || index < visibleItems.length - 1; 110 const safeGapAngle = Math.min(gapAngle, Math.max(segmentAngle - 0.1, 0)); 111 const halfGapAngle = safeGapAngle / 2; 112 const segmentStart = currentAngle + (hasLeadingGap ? halfGapAngle : 0); 113 const segmentEnd = currentAngle + segmentAngle - (hasTrailingGap ? halfGapAngle : 0); 114 115 currentAngle += segmentAngle; 116 117 if (segmentEnd <= segmentStart) { 118 return ""; 119 } 120 121 return `<path d="${describeArc(variant.centerX, variant.centerY, variant.radius, segmentStart, segmentEnd)}" stroke="${escapeHtml(item.color)}" />`; 122 }) 123 .join(""); 124 125 const LEGEND_LIMIT = 20; 126 const allRows = items.map( 127 (item, index) => ` 128 <div class="donut-legend-row${index >= LEGEND_LIMIT ? " donut-legend-row--hidden" : ""}"> 129 <span class="donut-label-wrap"> 130 <span class="donut-dot" style="background:${escapeHtml(item.color)}"></span>
131 <span class="donut-label">${escapeHtml(item.label)}</span> 132 </span> 133 <span class="donut-value">${item.value.toLocaleString('en-US')}</span> 134 </div> 135 `, 136 ).join(""); 137 138 const hasMore = items.length > LEGEND_LIMIT; 139 const toggleBtn = hasMore 140 ? `<button class="donut-legend-toggle" type="button" data-show-more="false" style="display:none;">Show ${items.length - LEGEND_LIMIT} more</button>` 141 : ""; 142 143 return ` 144 <div class="donut-figure donut-figure--${variantName}"> 145 <svg class="donut-svg" viewBox="${variant.viewBox}" aria-hidden="true" focusable="false"> 146 <g class="donut-arcs">${paths}</g> 147 </svg> 148 <div class="${variant.totalClassName}"> 149 <span class="donut-total-label">${escapeHtml(totalLabel)}</span> 150 <strong class="donut-total-value">${total.toLocaleString('en-US')}</strong> 151 </div> 152 </div> 153 <div class="donut-legend${variantName === "full" ? " donut-legend--scroll" : ""}">${allRows}${toggleBtn}</div> 154 `; 155}; 156 157const getChartConfig = (root) => { 158 const config = parseJsonConfig(root); 159 160 if (config?.items?.length) { 161 const items = config.items.map(normaliseItem); 162 const total = Number.isFinite(Number(config.total)) 163 ? Math.max(0, Number(config.total)) 164 : items.reduce((sum, item) => sum + item.value, 0); 165 166 return { 167 items, 168 legendLabel: config.legendLabel || `${config.title || "Chart"} breakdown`, 169 title: config.title || root.getAttribute("data-chart-title") || "Chart", 170 total, 171 totalLabel: config.totalLabel || "Total Jobs:", 172 variant: config.variant === "full" ? "full" : "half", 173 }; 174 } 175 176 const items = [ 177 { ...SEGMENTS[0], value: getValue(root, "data-onsite") }, 178 { ...SEGMENTS[1], value: getValue(root, "data-remote") }, 179 { ...SEGMENTS[2], value: getValue(root, "data-not-specific") }, 180 ].map(normaliseItem); 181 182 return { 183 items, 184 legendLabel: root.getAttribute("data-chart-legend-label") || "Jobs by remote status breakdown", 185 title: root.getAttribute("data-chart-title") || "Jobs by remote status", 186 total: items.reduce((sum, item) => sum + item.value, 0), 187 totalLabel: root.getAttribute("data-total-label") || "Total Jobs:", 188 variant: root.getAttribute("data-chart-variant") === "full" ? "full" : "half", 189 }; 190}; 191 192const renderChart = (root, items, legendLabel, title, total, totalLabel, variant) => { 193 root.setAttribute("data-chart-variant", variant); 194 root.innerHTML = ` 195 <header class="header center"> 196 <h3 class="title">${escapeHtml(title)}</h3> 197 </header> 198 <div class="donut-chart" aria-label="${escapeHtml(legendLabel)}"> 199 ${buildArcMarkup(items, variant, totalLabel, total)} 200 </div> 201 `; 202}; 203 204export default function GDMRemoteStatusChart({ rootSelector = "[data-gdm-remote-status-chart], [data-gdm-donut-chart]" } = {}) { 205 const roots = document.querySelectorAll(rootSelector); 206 if (!roots.length) return; 207 208 const instances = []; 209 210 roots.forEach((root) => { 211 const originalTitle = root.querySelector(".title")?.textContent?.trim() || null; 212 const initialConfig = getChartConfig(root); 213 if (originalTitle && initialConfig.title === "Jobs by remote status") { 214 initialConfig.title = originalTitle; 215 } 216 217 // Render with initial config (may be empty if no static data) 218 renderChart( 219 root, 220 initialConfig.items, 221 initialConfig.legendLabel, 222 initialConfig.title, 223 initialConfig.total, 224 initialConfig.totalLabel, 225 initialConfig.variant, 226 ); 227 228 const instance = { 229 root, 230 /** 231 * Re-render the chart with live data from the analytics AJAX response. 232 * @param {Array<{label: string, value: number}>} liveItems 233 */ 234 setData(liveItems) { 235 if (!Array.isArray(liveItems) || !liveItems.length) return; 236 237 const variant = root.getAttribute("data-chart-variant") === "full" ? "full" : "half"; 238 const title = initialConfig.title; 239 const legendLabel = `${title} breakdown`; 240 const totalLabel = "Total Jobs:"; 241 242 const items = liveItems.map((item, index) => ({ 243 color: PALETTE[index % PALETTE.length], 244 label: item.label || `Item ${index + 1}`, 245 value: Math.max(0, Number(item.value) || 0), 246 })); 247 248 // For remote-status (half variant) keep the fixed 3-color SEGMENTS mapping 249 const isRemote = variant === "half"; 250 const coloredItems = isRemote 251 ? liveItems.map((item, index) => ({ 252 color: SEGMENTS[index % SEGMENTS.length]?.color || "#62b4ea", 253 label: item.label || `Item ${index + 1}`, 254 value: Math.max(0, Number(item.value) || 0), 255 })) 256 : items; 257 258 const total = coloredItems.reduce((sum, item) => sum + item.value, 0); 259 260 const donutContainer = root.querySelector(".donut-chart"); 261 if (donutContainer) { 262 donutContainer.innerHTML = buildArcMarkup(coloredItems, variant, totalLabel, total); 263 } else { 264 renderChart(root, coloredItems, legendLabel, title, total, totalLabel, variant); 265 } 266 }, 267 }; 268 269 instances.push(instance); 270 }); 271 272 // Expose so search-view.php inline script can call: 273 // window.GDMRemoteStatusChartInstances[0].setData(analyticsData.remote_status) 274 window.GDMRemoteStatusChartInstances = instances; 275 276 // Toggle "show more / show less" for legend rows beyond 20 277 document.addEventListener("click", (e) => { 278 const btn = e.target.closest(".donut-legend-toggle"); 279 if (!btn) return; 280 const legend = btn.closest(".donut-legend"); 281 const hidden = legend.querySelectorAll(".donut-legend-row--hidden");
282 const isExpanded = btn.dataset.showMore === "false"; 283 hidden.forEach((row) => row.classList.toggle("donut-legend-row--hidden", isExpanded)); 284 btn.dataset.showMore = isExpanded ? "true" : "false"; 285 btn.textContent = isExpanded 286 ? `Show ${hidden.length} more` 287 : "Show less"; 288 }); 289 290 return instances; 291}
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.