1/* charts.js â auto-initialises <div class="chart-block"> elements. 2 * 3 * Required: <script src="vendor/chart.umd.min.js"></script> before this file. 4 * 5 * Minimal usage: 6 * <div class="chart-block" 7 * data-labels="Product A||Product B||Product C" 8 * data-values="400,300,250"></div> 9 * 10 * Optional attributes: 11 * data-title="Sales" heading below the type buttons (shown only if data-title-display="1"; always used for download) 12 * data-title-display="1" show the title on the page (default hidden) 13 * data-type="horizontalBar" initial type: horizontalBar|bar|pie|doughnut|line (default horizontalBar) 14 * data-types="bar,line" restrict which tabs are shown (default: all) 15 * data-height="320" chart height in px (default 360) 16 * data-filename="sales" downloaded PNG filename without .png (default "chart") 17 */ 18 const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); 19(function () { 20 // Palettes. All start red and end green so any size renders consistently. 21 const PALETTES = { 22 classic: { 23 label: "Classic", 24 stops: ["#e94e1b", "#f39200", "#fbb900", "#2bb8a3", "#10b981"], 25 curated: { 26 2: ["#e94e1b", "#10b981"], 27 3: ["#e94e1b", "#fbb900", "#10b981"], 28 4: ["#e94e1b", "#f39200", "#2bb8a3", "#10b981"], 29 5: ["#e94e1b", "#f39200", "#fbb900", "#2bb8a3", "#10b981"], 30 }, 31 }, 32 bold: { 33 label: "Bold", 34 stops: ["#e63946", "#ee7a1a", "#f5b900", "#104558", "#1d6e3e"], 35 curated: { 36 2: ["#e63946", "#1d6e3e"], 37 3: ["#e63946", "#f5b900", "#1d6e3e"], 38 4: ["#e63946", "#f5b900", "#104558", "#1d6e3e"], 39 5: ["#e63946", "#ee7a1a", "#f5b900", "#104558", "#1d6e3e"], 40 }, 41 }, 42 ocean: { 43 label: "Ocean", 44 stops: ["#345077", "#95bdd6", "#d6f5f5", "#ec5e44", "#1f2a38"], 45 curated: { 46 2: ["#345077", "#ec5e44"], 47 3: ["#345077", "#d6f5f5", "#ec5e44"], 48 4: ["#345077", "#95bdd6", "#ec5e44", "#1f2a38"], 49 5: ["#345077", "#95bdd6", "#d6f5f5", "#ec5e44", "#1f2a38"], 50 }, 51 }, 52 }; 53 const PALETTE_ORDER = ["classic", "bold", "ocean"]; 54 55 const hexToRgb = h => { const m=h.replace("#",""); return [parseInt(m.slice(0,2),16),parseInt(m.slice(2,4),16),parseInt(m.slice(4,6),16)]; }; 56 const rgbToHex = (r,g,b) => "#" + [r,g,b].map(v => Math.round(v).toString(16).padStart(2,"0")).join(""); 57 const hexAlpha = (h,a) => { const r=hexToRgb(h); return `rgba(${r[0]},${r[1]},${r[2]},${a})`; }; 58 const lerp = (a,b,t) => a + (b-a)*t; 59 function sampleStops(t, s) { 60 if (t<=0) return s[0]; if (t>=1) return s[s.length-1]; 61 const seg = t*(s.length-1), i = Math.floor(seg), f = seg-i; 62 const c1=hexToRgb(s[i]), c2=hexToRgb(s[i+1]); 63 return rgbToHex(lerp(c1[0],c2[0],f),lerp(c1[1],c2[1],f),lerp(c1[2],c2[2],f)); 64 } 65 function getPalette(n, name) { 66 const p = PALETTES[name] || PALETTES.classic; 67 if (n <= 1) return [p.stops[0]]; 68 if (p.curated[n]) return p.curated[n].slice(); 69 return Array.from({length:n}, (_,i) => sampleStops(i/(n-1), p.stops)); 70 } 71 72 73 const ICONS = { 74 pie: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 2.05V11H2.05A10 10 0 0 1 11 2.05Zm2 0A10 10 0 0 1 21.95 11H13V2.05ZM21.95 13A10 10 0 1 1 2.05 13H21.95Z"/></svg>', 75 doughnut: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 4a6 6 0 1 1 0 12 6 6 0 0 1 0-12Z"/></svg>', 76 bar: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="13" width="4" height="8"/><rect x="10" y="8" width="4" height="13"/><rect x="17" y="3" width="4" height="18"/><rect x="2" y="21" width="20" height="1.2"/></svg>', 77 horizontalBar: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="8" height="4"/><rect x="3" y="10" width="14" height="4"/><rect x="3" y="17" width="18" height="4"/></svg>', 78 line: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><polyline points="6 16 10 11 14 13 20 6"/></svg>', 79 }; 80 const LABELS = { pie:"Filled Pie", doughnut:"Donut", bar:"Vertical Bar", horizontalBar:"Horizontal Bar", line:"Line" }; 81 const DEFAULT_TYPES = ["horizontalBar","bar","pie","doughnut","line"]; 82 83 // Wrap a label to multiple lines by whole words. Max chars per line. 84 // Words longer than max are split into two parts with a hyphen; first part max 8 chars. 85 function wrapLabel(text, max, longSplit = 8) { 86 if (!text || text.length <= max) return text; 87 const words = text.split(/\s+/); 88 const lines = [];
89 let cur = ""; 90 91 function hyphenate(word) { 92 if (word.length <= max) return [word]; 93 return [word.slice(0, longSplit) + "-", word.slice(longSplit)]; 94 } 95 96 for (const w of words) { 97 for (const part of hyphenate(w)) { 98 if (!cur) { cur = part; continue; } 99 if (cur.length + 1 + part.length <= max) cur += " " + part; 100 else { lines.push(cur); cur = part; } 101 } 102 } 103 if (cur) lines.push(cur); 104 return lines; 105 } 106 107 // Shorten a long label to fit within a character budget while preserving whole words. 108 // Adds a trailing ellipsis when the text is truncated. 109 function truncateByWords(text, maxChars, suffix = "...") { 110 if (text.length <= maxChars) return text; 111 const words = text.split(/\s+/); 112 let cur = ""; 113 let result = ""; 114 for (const w of words) { 115 const sep = cur ? " " : ""; 116 const next = cur + sep + w; 117 if (next.length + suffix.length <= maxChars) { cur = next; result = cur + suffix; } 118 else break; 119 } 120 if (result) return result; 121 return text.slice(0, Math.max(0, maxChars - suffix.length)) + suffix; 122 } 123 124 // Wrap a string into lines that fit within a canvas pixel width. 125 // Preserves explicit line breaks; wraps long segments on whole words. 126 function fitLines(ctx, text, maxWidth) { 127 const paragraphs = String(text).replace(/\r\n/g, "\n").split("\n"); 128 const lines = []; 129 for (const para of paragraphs) { 130 const words = para.split(/\s+/).filter(Boolean); 131 if (!words.length) { lines.push(""); continue; } 132 let cur = ""; 133 for (const w of words) { 134 const test = cur ? cur + " " + w : w; 135 if (ctx.measureText(test).width > maxWidth && cur) { 136 lines.push(cur); 137 cur = w; 138 } else { 139 cur = test; 140 } 141 } 142 if (cur) lines.push(cur); 143 } 144 return lines.length ? lines : [""]; 145 } 146 147 148 // Plugin: draws data labels on top of each chart. 149 // - pie / doughnut: "NN%" centered in each slice 150 // - bar / horizontalBar / line: "value (NN%)" near each point/bar 151 const dataLabelsPlugin = { 152 id: "cbDataLabels", 153 afterDatasetsDraw(chart) { 154 const { ctx, config } = chart; 155 const type = chart.cbType || config.type; 156 const meta = chart.getDatasetMeta(0); 157 const ds = chart.data.datasets[0]; 158 if (!meta || !ds) return; 159 const values = ds.data; 160 const total = values.reduce((a,b) => a + (Number(b) || 0), 0) || 1; 161 162 ctx.save(); 163 ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; 164 ctx.textAlign = "center"; 165 ctx.textBaseline = "middle"; 166 167 meta.data.forEach((el, i) => { 168 const v = Math.round(Number(values[i]) || 0); 169 const pct = Math.round((v / total) * 100); 170 171 if (type === "pie" || type === "doughnut") { 172 // Skip tiny slices to avoid clutter 173 if (pct < 4) return; 174 const pos = el.tooltipPosition ? el.tooltipPosition() : { x: el.x, y: el.y }; 175 ctx.fillStyle = "#fff"; 176 ctx.shadowColor = "rgba(0,0,0,0.35)"; 177 ctx.shadowBlur = 3; 178 ctx.fillText(pct + "%", pos.x, pos.y); 179 ctx.shadowBlur = 0; 180 } else { 181 const text = v + " (" + pct + "%)"; 182 ctx.fillStyle = "#374151"; 183 if (type === "horizontalBar") { 184 ctx.textAlign = "left"; 185 ctx.textBaseline = "middle"; 186 ctx.fillText(text, el.x + 6, el.y); 187 ctx.textAlign = "center"; 188 } else if (type === "line") { 189 // Place label on the side of the point away from the line, so 190 // descending/ascending segments don't cross the text. 191 const prev = meta.data[i - 1]; 192 const next = meta.data[i + 1]; 193 const prevY = prev ? prev.y : el.y; 194 const nextY = next ? next.y : el.y; 195 // Default: above the point. If both neighbors are above (valley), below.
196 const isValley = prevY < el.y && nextY < el.y; 197 const off = 10; 198 if (isValley) { 199 ctx.textBaseline = "top"; 200 ctx.fillText(text, el.x, el.y + off); 201 } else { 202 ctx.textBaseline = "bottom"; 203 // Shift horizontally away from the steeper neighbor so the 204 // label doesn't sit on top of the descending/ascending line. 205 const dPrev = prevY - el.y; // >0 means prev is below 206 const dNext = nextY - el.y; // >0 means next is below 207 let dx = 0; 208 if (prev && next) { 209 // Shift toward the lower neighbor (line goes away from label) 210 if (dPrev > dNext) dx = -12; // prev lower â shift left 211 else if (dNext > dPrev) dx = 12; // next lower â shift right 212 } 213 ctx.fillText(text, el.x + dx, el.y - off); 214 } 215 ctx.textBaseline = "middle"; 216 } else { 217 // Vertical bar: stack value above % when on mobile or 8+ bars 218 const isMobile = (typeof window !== "undefined") && 219 window.matchMedia && window.matchMedia("(max-width: 640px)").matches; 220 const stack = (type === "bar") && (isMobile || values.length >= 8); 221 ctx.textBaseline = "bottom"; 222 if (stack) { 223 ctx.fillText(v, el.x, el.y - 14); 224 ctx.fillText("(" + pct + "%)", el.x, el.y - 2); 225 } else { 226 ctx.fillText(text, el.x, el.y - 2); 227 } 228 ctx.textBaseline = "middle"; 229 } 230 } 231 }); 232 ctx.restore(); 233 }, 234 }; 235 236 function buildConfig(type, labels, values, paletteName) { 237 const colors = getPalette(values.length, paletteName); 238 let chartType = type; 239 const total = values.reduce((a,b) => a + (Number(b)||0), 0) || 1; 240 const isPieLike = (type === "pie" || type === "doughnut"); 241 const options = { 242 responsive: true, 243 maintainAspectRatio: false, 244 // Include touch events so tap-to-show tooltip works on mobile 245 events: ["mousemove","mouseout","click","touchstart","touchmove","touchend"], 246 interaction: { mode: "nearest", intersect: true }, 247 plugins: { 248 legend: { 249 position: "bottom", 250 labels: { 251 color: "#111827", boxWidth: 12, padding: 14, 252 // For pie/doughnut, embed value + % directly in the legend so 253 // mobile users see all data without relying on hover/tooltip. 254 generateLabels: isPieLike ? function(chart) { 255 const data = chart.data; 256 const ds = data.datasets[0] || {}; 257 const bg = ds.backgroundColor || []; 258 return (data.labels || []).map((label, i) => { 259 let text = String(label || ""); 260 if (text.length > 80) { 261 text = truncateByWords(text, 80, "..."); 262 } 263 return { 264 text: text, 265 fillStyle: Array.isArray(bg) ? bg[i] : bg, 266 strokeStyle: Array.isArray(bg) ? bg[i] : bg, 267 hidden: false, 268 index: i, 269 }; 270 }); 271 } : undefined, 272 }, 273 }, 274 tooltip: { 275 backgroundColor: "#fff", borderColor: "#e5e7eb", borderWidth: 1, titleColor: "#111827", bodyColor: "#111827", 276 callbacks: { 277 label: function(ctx) { 278 const v = Math.round(Number(ctx.parsed?.y ?? ctx.parsed?.x ?? ctx.parsed) || 0); 279 const data = ctx.dataset.data || []; 280 const t = Math.round(data.reduce((a,b) => a + (Number(b)||0), 0)) || 1; 281 const pct = Math.round((v / t) * 100); 282 const name = ctx.label || ctx.dataset.label || ""; 283 return (name ? name + ": " : "") + v + " (" + pct + "%)"; 284 } 285 } 286 }, 287 }, 288 }; 289 const dataset = { data: values, backgroundColor: colors }; 290 if (type === "pie" || type === "doughnut") { 291 dataset.borderColor = "#fff"; dataset.borderWidth = 2; dataset.hoverOffset = 20; 292 if (type === "pie") { chartType = "doughnut"; options.cutout = "0%"; } 293 else { options.cutout = "55%"; } 294 } else if (type === "bar" || type === "horizontalBar") { 295 dataset.borderRadius = 6; 296 const horiz = type === "horizontalBar"; 297 if (horiz) { chartType = "bar"; options.indexAxis = "y"; dataset.maxBarThickness = 42; } 298 options.plugins.legend = { display: false }; 299 options.scales = { 300 x: { grid: { color: "#e5e7eb" }, ticks: { color: "#4f4f4f" } }, 301 y: { grid: { color: "#e5e7eb" }, ticks: { color: "#4f4f4f" } }, 302 }; 303 if (horiz) { 304 options.scales.y.grid = { display: false }; 305 options.scales.y.ticks.autoSkip = false;
306 options.scales.y.ticks.maxRotation = 0; 307 options.scales.y.ticks.minRotation = 0; 308 options.scales.y.ticks.callback = function(value) { 309 const raw = this.getLabelForValue(value); 310 const isNarrow = (typeof window !== "undefined") && 311 window.matchMedia && window.matchMedia("(max-width: 640px)").matches; 312 const maxLine = isNarrow ? 20 : 40; 313 const maxChars = isNarrow ? 34 : 75; 314 let text = String(raw); 315 if (text.length > maxChars) { 316 text = truncateByWords(text, maxChars, "..."); 317 } 318 return wrapLabel(text, maxLine, isNarrow ? 7 : 8); 319 }; 320 321 } 322 else { 323 options.scales.x.grid = { display: false }; 324 options.scales.x.ticks.callback = function(value) { 325 const raw = this.getLabelForValue(value); 326 const isNarrow = (typeof window !== "undefined") && 327 window.matchMedia && window.matchMedia("(max-width: 640px)").matches; 328 let text = String(raw); 329 if (text.length > 80) { 330 text = truncateByWords(text, 80, "..."); 331 } 332 return wrapLabel(text, isNarrow ? 8 : 12); 333 }; 334 options.scales.x.ticks.maxRotation = 0; 335 options.scales.x.ticks.minRotation = 0; 336 options.scales.x.ticks.autoSkip = false; 337 } 338 } else if (type === "line") { 339 dataset.borderColor = colors[0]; 340 dataset.backgroundColor = hexAlpha(colors[0], 0.15);
341 dataset.pointBackgroundColor = colors; 342 dataset.pointBorderColor = colors; 343 dataset.pointRadius = 5; dataset.pointHoverRadius = 7; 344 dataset.tension = 0.35; dataset.fill = true; 345 options.plugins.legend = { display: false }; 346 options.scales = { 347 x: { 348 grid: { display: false }, 349 ticks: { 350 color: "#4f4f4f", 351 maxRotation: 0, 352 minRotation: 0, 353 autoSkip: false, 354 callback: function(value) { 355 const raw = this.getLabelForValue(value); 356 const isNarrow = (typeof window !== "undefined") && 357 window.matchMedia && window.matchMedia("(max-width: 640px)").matches; 358 let text = String(raw); 359 if (text.length > 80) { 360 text = truncateByWords(text, 80, "..."); 361 } 362 return wrapLabel(text, isNarrow ? 8 : 12); 363 }, 364 }, 365 offset: true, 366 }, 367 y: { grid: { color: "#e5e7eb" }, ticks: { color: "#4f4f4f" } }, 368 }; 369 } 370 return { chartType, labels, dataset, options }; 371 } 372 373 function parseList(s) { return (s||"").split(",").map(x => x.trim()).filter(x => x.length); } 374 function parseLabels(s) { return (s||"").split("||").map(x => x.trim()).filter(x => x.length); } 375 376 function initBlock(el) { 377 if (el.dataset.cbInit) return; 378 el.dataset.cbInit = "1"; 379 380 const labels = parseLabels(el.dataset.labels); 381 const values = parseList(el.dataset.values).map(Number); 382 if (!labels.length || labels.length !== values.length) { 383 console.warn("[chart-block] labels/values missing or mismatched", el); 384 return; 385 } 386 const title = el.dataset.title || ""; 387 const titleDisplay = el.dataset.titleDisplay === "1"; 388 const totalAns = (el.dataset.totalAns || "").trim(); 389 const filename = (el.dataset.filename || "").trim(); 390 391 const types = parseList(el.dataset.types).filter(t => DEFAULT_TYPES.includes(t)); 392 const tabs = types.length ? types : DEFAULT_TYPES; 393 let current = el.dataset.type && tabs.includes(el.dataset.type) ? el.dataset.type : tabs[0]; 394 let palette = PALETTES[el.dataset.palette] ? el.dataset.palette : PALETTE_ORDER[0]; 395 let height = parseInt(el.dataset.height, 10) || 360; 396 if (labels.length <= 3) height = Math.round(height * 0.8); 397 else if (labels.length >= 12) height = Math.round(height * 1.5); 398 else if (labels.length >= 8) height = Math.round(height * 1.25); 399 el.style.setProperty("--cb-height", height + "px"); 400 401 const swatchSvg = name => { 402 const cols = PALETTES[name].curated[5]; 403 const w = 18 / cols.length; 404 return '<svg viewBox="0 0 18 18" aria-hidden="true">' + 405 cols.map((c,i) => `<rect x="${i*w}" y="0" width="${w}" height="18" fill="${c}"/>`).join("") + 406 '</svg>'; 407 }; 408 409 el.innerHTML = ` 410 <div class="cb-toolbar"> 411 <div class="cb-tabs"> 412 ${tabs.map(t => `<button type="button" data-type="${t}" title="${LABELS[t]}" aria-label="${LABELS[t]}">${ICONS[t]}</button>`).join("")} 413 </div> 414 <div class="cb-right"> 415 <div class="cb-palettes"> 416 ${PALETTE_ORDER.map(p => `<button type="button" data-palette="${p}" title="${PALETTES[p].label} palette" aria-label="${PALETTES[p].label} palette">${swatchSvg(p)}</button>`).join("")} 417 </div> 418 <button type="button" class="cb-download" title="Download PNG" aria-label="Download PNG"> 419 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 10 12 15 17 10"/><path d="M5 21h14"/></svg> 420 </button> 421 </div> 422 </div> 423 ${totalAns ? `<div class="cb-total">${escapeHtml(totalAns)}</div>` : ""} 424 ${title && titleDisplay ? `<div class="cb-title">${escapeHtml(title)}</div>` : ""} 425 <div class="cb-canvas-wrap"><canvas></canvas></div>`; 426 427 const canvas = el.querySelector("canvas"); 428 let chart = null; 429 430 function render() { 431 const { chartType, labels: l, dataset, options } = buildConfig(current, labels, values, palette); 432 if (chart) chart.destroy(); 433 // Reserve space at the top so vertical bar and line value labels don't get clipped 434 if (current === "bar") { 435 options.layout = { padding: { top: 42 } }; 436 } else if (current === "line") { 437 options.layout = { padding: { top: 22 } }; 438 } else if (current === "horizontalBar") { 439 options.layout = { padding: { right: 60 } }; 440 } 441 chart = new Chart(canvas.getContext("2d"), { 442 type: chartType, 443 data: { labels: l, datasets: [dataset] }, 444 options, 445 plugins: [dataLabelsPlugin], 446 }); 447 chart.cbType = current; 448 449 // Mobile: tap a slice/bar/point to pin its tooltip; tap empty area to dismiss.
450 // Without this, Chart.js shows the tooltip on touchstart but hides it as 451 // soon as the finger lifts, so values feel "hover-only" on phones. 452 canvas.addEventListener("touchend", (ev) => { 453 if (!chart) return; 454 const points = chart.getElementsAtEventForMode(ev, "nearest", { intersect: true }, false); 455 if (points && points.length) { 456 chart.setActiveElements(points); 457 chart.tooltip.setActiveElements(points, { x: 0, y: 0 }); 458 } else { 459 chart.setActiveElements([]); 460 chart.tooltip.setActiveElements([], { x: 0, y: 0 }); 461 } 462 chart.update(); 463 ev.preventDefault(); 464 }, { passive: false }); 465 466 el.querySelectorAll(".cb-tabs button").forEach(b => b.classList.toggle("active", b.dataset.type === current)); 467 el.querySelectorAll(".cb-palettes button").forEach(b => b.classList.toggle("active", b.dataset.palette === palette)); 468 } 469 470 471 472 el.querySelector(".cb-tabs").addEventListener("click", e => { 473 const btn = e.target.closest("button[data-type]"); 474 if (!btn) return; 475 current = btn.dataset.type; 476 render(); 477 }); 478 479 el.querySelector(".cb-palettes").addEventListener("click", e => { 480 const btn = e.target.closest("button[data-palette]"); 481 if (!btn) return; 482 palette = btn.dataset.palette; 483 render(); 484 }); 485 486 el.querySelector(".cb-download").addEventListener("click", () => { 487 if (!chart) return; 488 const src = chart.canvas; 489 const out = document.createElement("canvas"); 490 const titleFontSize = 18; // title size for download 491 const margin = 36; // 18px left + 18px right 492 const ctx = out.getContext("2d"); 493 494 let lines = []; 495 let titleHeight = 0; 496 const lineHeight = titleFontSize * 1.2; 497 if (title) { 498 // Measure with a temp context (setting canvas size later resets ctx state). 499 ctx.font = `400 ${titleFontSize}px ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif`; 500 const maxWidth = Math.max(src.width - margin, 1); 501 lines = fitLines(ctx, title, maxWidth); 502 titleHeight = 16 + lines.length * lineHeight + 16; 503 } 504 505 // Set final dimensions BEFORE any painting (resizing a canvas resets state). 506 out.width = src.width; 507 out.height = src.height + titleHeight; 508 509 ctx.fillStyle = "#ffffff"; 510 ctx.fillRect(0, 0, out.width, out.height); 511 512 if (title) { 513 ctx.font = `400 ${titleFontSize}px ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif`; 514 ctx.fillStyle = "#111827"; 515 ctx.textAlign = "center"; 516 ctx.textBaseline = "top"; 517 lines.forEach((ln, i) => ctx.fillText(ln, out.width / 2, 16 + i * lineHeight)); 518 } 519 ctx.drawImage(src, 0, titleHeight); 520 const link = document.createElement("a"); 521 const safe = (filename || title || current || "chart") 522 .toLowerCase() 523 .replace(/[^a-z0-9]+/g, "-") 524 .replace(/^-|-$/g, "") || "chart"; 525 link.download = `${safe}.png`; 526 link.href = out.toDataURL("image/png"); 527 link.click(); 528 }); 529 530 render(); 531 } 532 533 534 function initAll(root) { 535 (root || document).querySelectorAll(".chart-block").forEach(initBlock); 536 } 537 538 if (document.readyState === "loading") { 539 document.addEventListener("DOMContentLoaded", () => initAll()); 540 } else { 541 initAll(); 542 } 543 544 window.ChartBlock = { init: initAll, initOne: initBlock }; 545})();
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.