// ============================================================================ // Project : modelmap // File : site/lib/charts.js // Purpose : Server-rendered SVG charts for real result maps (dataviz spec) // Author : Simon-Pierre Boucher // Contact : contact@spboucher.ai // Website : https://modelmap.io // Created : 2026-08-12 // Modified : 2026-08-12 // Platform : macOS / Apple Silicon (arm64) — Node.js (deployed on MacLustr) // License : All rights reserved (research code) // ============================================================================ "use strict"; // Categorical palette validated with the dataviz six-checks script // (violet/orange PASS all; aqua passes with a contrast WARN → bars using it // always carry direct value labels). The twin/null is NOT a categorical slot: // it renders as a dashed neutral reference line, direct-labeled. const S1 = "#6d4fc4"; // series 1 — violet const S2 = "#eb6834"; // series 2 — orange const S3 = "#1baf7a"; // series 3 — aqua (direct labels mandatory) const REF = "#8b8798"; // reference/null line (muted ink, dashed) function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } const fmt = (v) => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)); /** * Layer-profile line chart: x = layer index, y in [yMin,yMax]. * series: [{label, color, dash?, ref?, values:[y per layer]}] * Direct labels at line ends + legend; per-point hover targets. */ function layerLineSvg({ title, series, yLabel, yMin = 0, yMax = 1, caption, xTitle = "layer", xTickLabels = null }) { const W = 720, H = 300, ML = 52, MR = 118, MT = 16, MB = 40; const iw = W - ML - MR, ih = H - MT - MB; const nx = Math.max(...series.map((s) => s.values.length)); const X = (i) => ML + (i / (nx - 1)) * iw; const Y = (v) => MT + ih - ((v - yMin) / (yMax - yMin)) * ih; let grid = ""; const defaultTicks = [0, 0.25, 0.5, 0.75, 1].filter((v) => v >= yMin && v <= yMax); const ticks = (yMin >= 0 && yMax <= 1 && defaultTicks.length >= 3) ? defaultTicks : [0, 1, 2, 3, 4].map((i) => yMin + (i / 4) * (yMax - yMin)); for (const v of ticks) { grid += `` + `${fmt(v)}`; } if (yMin < 0 && yMax > 0) { grid += ``; } let xt = ""; const xlab = (i) => (xTickLabels ? (xTickLabels[i] ?? "") : String(i)); for (let i = 0; i < nx; i += Math.ceil(nx / 8)) { xt += `${xlab(i)}`; } if (!xTickLabels) xt += `${xlab(nx - 1)}`; else for (let i = 1; i < nx; i += 1) if (i % Math.ceil(nx / 8) !== 0) xt += `${xlab(i)}`; let lines = "", dots = "", labels = ""; const usedY = []; for (const s of series) { const d = s.values.map((v, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(v).toFixed(1)}`).join(" "); lines += ``; s.values.forEach((v, i) => { dots += ``; }); let ly = Y(s.values[s.values.length - 1]); while (usedY.some((u) => Math.abs(u - ly) < 13)) ly += 13; // collision nudge usedY.push(ly); labels += `${esc(s.label)}`; } const legend = series.map((s) => `${esc(s.label)}`).join(""); return `
${title ? `
${esc(title)}
` : ""} ${grid}${xt}${lines}${dots}${labels} ${esc(xTitle)} ${esc(yLabel)}
${legend}
${caption ? `
${caption}
` : ""}
`; } /** * Small-multiple bar panels sharing one y-scale (identity on the x axis → * single hue; every bar direct-labeled; 4px rounded data ends; 2px gaps). * panels: [{title, bars: [{label, value}]}] */ function barPanelsSvg({ title, panels, yLabel, unit = "", color = S1, caption }) { const PW = 340, H = 280, ML = 46, MR = 8, MT = 30, MB = 58; const ih = H - MT - MB; const vmax = Math.max(...panels.flatMap((p) => p.bars.map((b) => b.value))) * 1.15; const W = panels.length * PW; let out = ""; panels.forEach((panel, pi) => { const x0 = pi * PW; const iw = PW - ML - MR; const n = panel.bars.length; const bw = Math.min(46, (iw / n) * 0.62); out += `${esc(panel.title)}`; for (const v of [0.25, 0.5, 0.75, 1]) { const y = MT + ih - v * ih; out += ``; if (pi === 0) out += `${fmt(vmax * v)}`; } panel.bars.forEach((b, i) => { const cx = x0 + ML + ((i + 0.5) / n) * iw; const h = Math.max(2, (b.value / vmax) * ih); const y = MT + ih - h; out += `` + `` + `${fmt(b.value)}` + `${esc(b.label)}`; }); out += ``; }); return `
${title ? `
${esc(title)}
` : ""} ${out} ${esc(yLabel)} ${caption ? `
${caption}
` : ""}
`; } /** * Grouped bars: groups on x, one bar per mode, fixed mode→color assignment, * every bar direct-labeled (satisfies the aqua contrast WARN relief). * groups: [{label, values: {modeKey: value}}], modes: [{key,label,color}] */ function groupedBarSvg({ title, groups, modes, yLabel, unit = "", caption }) { const W = 720, H = 300, ML = 52, MR = 10, MT = 16, MB = 58; const iw = W - ML - MR, ih = H - MT - MB; const vmax = Math.max(...groups.flatMap((g) => modes.map((m) => g.values[m.key] || 0))) * 1.18; const gw = iw / groups.length; const bw = Math.min(34, (gw / modes.length) * 0.6); let out = ""; for (const v of [0.25, 0.5, 0.75, 1]) { const y = MT + ih - v * ih; out += `` + `${fmt(vmax * v)}`; } groups.forEach((g, gi) => { const gx = ML + gi * gw + gw / 2; modes.forEach((m, mi) => { const v = g.values[m.key]; if (v == null) return; const cx = gx + (mi - (modes.length - 1) / 2) * (bw + 2); const h = Math.max(2, (v / vmax) * ih); const y = MT + ih - h; out += `` + `${fmt(v)}`; }); out += `${esc(g.label)}`; }); const legend = modes.map((m) => `${esc(m.label)}`).join(""); return `
${title ? `
${esc(title)}
` : ""} ${out} ${esc(yLabel)}
${legend}
${caption ? `
${caption}
` : ""}
`; } module.exports = { layerLineSvg, barPanelsSvg, groupedBarSvg, S1, S2, S3, REF };