/*
ArozOS Office Suite - shared SVG chart renderer
Used by Sheets (chart insertion) and Slides (chart objects).
No external dependencies; renders plain SVG that inherits text color
from the container (fill="currentColor"), so it is theme-aware.
Usage:
var svgString = OfficeCharts.renderToString(spec, width, height);
OfficeCharts.render(containerElement, spec); // sizes to container
Chart spec:
{
type: "bar" | "line" | "pie",
title: "Monthly sales", // optional
labels: ["Jan", "Feb", "Mar"], // category labels
series: [ // one or more series
{ name: "2025", values: [10, 20, 15], color: "#4c9be8" }
],
options: {
legend: true, // default true when >1 series (bar/line), always for pie
gridlines: true, // default true (bar/line)
stacked: false // bar only
}
}
*/
var OfficeCharts = (function () {
var PALETTE = [
"#4c9be8", "#e8734c", "#4cc06a", "#b06ae8",
"#e8b84c", "#4cc9c0", "#e84c8b", "#8a99a8"
];
function esc(t) {
return String(t === undefined || t === null ? "" : t)
.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}
function colorOf(series, i) {
return series.color || PALETTE[i % PALETTE.length];
}
function niceCeil(v) {
if (v <= 0) return 1;
var mag = Math.pow(10, Math.floor(Math.log10(v)));
var n = v / mag;
var nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
return nice * mag;
}
function fmtNum(v) {
if (Math.abs(v) >= 1000000) return (v / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1).replace(/\.0$/, "") + "k";
if (Math.abs(v) < 1 && v !== 0) return String(Math.round(v * 100) / 100);
return String(Math.round(v * 10) / 10);
}
function renderToString(spec, W, H) {
W = W || 640; H = H || 400;
spec = spec || {};
var type = spec.type || "bar";
var labels = spec.labels || [];
var series = (spec.series || []).filter(function (s) { return s && s.values; });
var opts = spec.options || {};
if (series.length === 0) series = [{ name: "", values: [] }];
var out = [];
out.push('");
return out.join("");
}
function renderXY(type, labels, series, opts, W, H, top, legendH) {
var out = [];
var padL = 46, padR = 16, padB = 30 + legendH;
var plotW = W - padL - padR;
var plotH = H - top - padB;
var n = Math.max(labels.length, series.reduce(function (m, s) { return Math.max(m, s.values.length); }, 0));
if (n === 0) n = 1;
var stacked = !!opts.stacked && type === "bar";
var maxV = 0, minV = 0;
for (var i = 0; i < n; i++) {
var stackSum = 0;
series.forEach(function (s) {
var v = Number(s.values[i]) || 0;
if (stacked) { stackSum += Math.max(0, v); }
else { if (v > maxV) maxV = v; if (v < minV) minV = v; }
});
if (stacked && stackSum > maxV) maxV = stackSum;
}
if (maxV <= 0 && minV >= 0) maxV = 1;
maxV = niceCeil(maxV);
minV = minV < 0 ? -niceCeil(-minV) : 0;
var range = maxV - minV;
function yOf(v) { return top + plotH - ((v - minV) / range) * plotH; }
// gridlines + y labels
var ticks = 5;
for (var t = 0; t <= ticks; t++) {
var val = minV + (range * t) / ticks;
var y = yOf(val);
if (opts.gridlines !== false) {
out.push('');
}
out.push('' + esc(fmtNum(val)) + "");
}
var slotW = plotW / n;
// x labels (skip some if crowded)
var skip = Math.ceil(n / Math.floor(plotW / 60));
for (var li = 0; li < n; li++) {
if (li % skip !== 0) continue;
var lx = padL + slotW * li + slotW / 2;
out.push('' +
esc(String(labels[li] === undefined ? li + 1 : labels[li]).substring(0, 12)) + "");
}
if (type === "bar") {
var groupPad = slotW * 0.18;
var innerW = slotW - groupPad * 2;
var barW = stacked ? innerW : innerW / series.length;
for (var ci = 0; ci < n; ci++) {
var acc = 0;
series.forEach(function (s, si) {
var v = Number(s.values[ci]) || 0;
var x = padL + slotW * ci + groupPad + (stacked ? 0 : barW * si);
var yTop, yBottom;
if (stacked) {
v = Math.max(0, v);
yTop = yOf(acc + v);
yBottom = yOf(acc);
acc += v;
} else {
yTop = yOf(Math.max(0, v));
yBottom = yOf(Math.min(0, v));
}
var bh = yBottom - yTop;
if (bh < 1 && v !== 0) bh = 1;
if (bh <= 0) return;
out.push('');
});
}
} else { // line
series.forEach(function (s, si) {
var pts = [];
for (var pi = 0; pi < n; pi++) {
var v = Number(s.values[pi]) || 0;
var x = padL + slotW * pi + slotW / 2;
pts.push(x + "," + yOf(v));
}
out.push('');
for (var di = 0; di < n; di++) {
var dv = Number(s.values[di]) || 0;
out.push('');
}
});
}
// axis line
out.push('');
return out.join("");
}
function renderPie(spec, labels, series, W, H, top, legendH) {
var out = [];
var values = (series[0] && series[0].values) || [];
var total = 0;
values.forEach(function (v) { total += Math.max(0, Number(v) || 0); });
var cx = W / 2, cy = top + (H - top - legendH - 16) / 2;
var r = Math.min(W / 2 - 30, (H - top - legendH - 24) / 2);
if (r < 10) r = 10;
if (total <= 0) {
out.push('');
out.push('No data');
return out.join("");
}
var a0 = -Math.PI / 2;
values.forEach(function (v, i) {
v = Math.max(0, Number(v) || 0);
if (v === 0) return;
var frac = v / total;
var a1 = a0 + frac * Math.PI * 2;
var large = frac > 0.5 ? 1 : 0;
var x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
var x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
var color = PALETTE[i % PALETTE.length];
if (frac >= 0.99999) {
out.push('');
} else {
out.push('');
}
// percentage label
if (frac > 0.04) {
var am = (a0 + a1) / 2;
var lx = cx + r * 0.62 * Math.cos(am);
var ly = cy + r * 0.62 * Math.sin(am);
out.push('' +
Math.round(frac * 100) + "%");
}
a0 = a1;
});
return out.join("");
}
function render(container, spec) {
var w = container.clientWidth || 640;
var h = container.clientHeight || 400;
container.innerHTML = renderToString(spec, w, h);
}
return {
render: render,
renderToString: renderToString,
palette: PALETTE.slice()
};
})();