PageSourceSearch

https://visidati.lv/js/charts.js?c=1

js visidati.lv collected 2026-10-02 10:53:05 UTC 23,370 bytes, 545 lines download raw bytes

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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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.