// ============================================================================
// 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)}` : ""}
${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)}` : ""}
${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)}` : ""}
${legend}
${caption ? `${caption}` : ""}
`;
}
module.exports = { layerLineSvg, barPanelsSvg, groupedBarSvg, S1, S2, S3, REF };