chart = {
const root = document.getElementById("atlas-chart");
root.innerHTML = "";
const ink = "#1C1916";
const verdigris = "#4A6B5C";
const ochre = "#C8985E";
const slate = "#6B6357";
// --- Karta (map) view: organic blobs per country, Statecraft style ---
if (view === "map") {
if (spec.kind === "cofog") {
root.innerHTML = `<div style="padding:3rem; text-align:center; color:#6B6357; font-style:italic;">
Karta nije primjenjiva za funkcionalnu strukturu rashoda.
</div>`;
return null;
}
const W = root.clientWidth || 900, H = 460;
// Project lon/lat → x/y using a stretched equirectangular fit to Europe.
const lonExt = [-12, 32], latExt = [34, 66];
const project = (lon, lat) => [
40 + ((lon - lonExt[0]) / (lonExt[1] - lonExt[0])) * (W - 80),
30 + ((latExt[1] - lat) / (latExt[1] - latExt[0])) * (H - 60)
];
// Names for label
const names = {
HR:"Hrvatska", DE:"Njemačka", SI:"Slovenija", HU:"Mađarska", AT:"Austrija",
IT:"Italija", PL:"Poljska", CZ:"Češka", SK:"Slovačka", RO:"Rumunjska",
BG:"Bugarska", FR:"Francuska", ES:"Španjolska", NL:"Nizozemska",
SE:"Švedska", DK:"Danska"
};
const latest = Math.max(...aggregated.map(d => +d.year));
const rows = aggregated
.filter(d => +d.year === latest && countryCentroid[d.geo])
.map(d => ({geo: d.geo, value: +d.value, lon: countryCentroid[d.geo][0], lat: countryCentroid[d.geo][1]}));
const vmin = Math.min(...rows.map(d => d.value));
const vmax = Math.max(...rows.map(d => d.value));
// Sequential green ramp (paper → verdigris-deep).
const ramp = ["#E8EFE9", "#C8D5CB", "#9CB7A4", "#6E9580", "#4A6B5C", "#3A5648"];
const colorOf = v => {
const t = (v - vmin) / (vmax - vmin || 1);
const i = Math.min(ramp.length - 1, Math.floor(t * ramp.length));
return ramp[i];
};
const radiusOf = v => 26 + 26 * ((v - vmin) / (vmax - vmin || 1));
// Deterministic PRNG so each country's blob is stable across re-renders.
const mulberry32 = (s) => () => {
s |= 0; s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const seedOf = (s) => { let h = 2166136261; for (const c of s) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return h >>> 0; };
// Catmull-Rom closed → smooth cubic Bezier path through points.
const blobPath = (cx, cy, r, seed) => {
const rand = mulberry32(seed);
const n = 10;
const pts = [];
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2 + (rand() - 0.5) * 0.25;
const rr = r * (0.78 + rand() * 0.42);
// Squash slightly horizontally for a more "country-like" feel.
pts.push([cx + Math.cos(ang) * rr * 1.15, cy + Math.sin(ang) * rr * 0.9]);
}
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n], p3 = pts[(i + 2) % n];
const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
}
return d + " Z";
};
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("width", "100%");
svg.setAttribute("height", H);
svg.style.fontFamily = "Public Sans, sans-serif";
// Draw blobs ordered largest → smallest so smaller ones overlay nicely.
rows.sort((a, b) => b.value - a.value).forEach(d => {
const [x, y] = project(d.lon, d.lat);
const r = radiusOf(d.value);
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", blobPath(x, y, r, seedOf(d.geo)));
path.setAttribute("fill", colorOf(d.value));
path.setAttribute("stroke", ink);
path.setAttribute("stroke-width", "0.8");
path.setAttribute("stroke-opacity", "0.55");
svg.appendChild(path);
const label = document.createElementNS(svgNS, "text");
label.setAttribute("x", x);
label.setAttribute("y", y);
label.setAttribute("text-anchor", "middle");
label.setAttribute("dominant-baseline", "middle");
label.setAttribute("font-size", "11");
label.setAttribute("fill", ink);
label.textContent = names[d.geo] || d.geo;
svg.appendChild(label);
});
root.appendChild(svg);
return svg;
}
let plot;
if (spec.kind === "cofog") {
const labels = {
GF01:"Opće javne usluge", GF02:"Obrana", GF03:"Javni red", GF04:"Ekonomske djelatnosti",
GF05:"Zaštita okoliša", GF06:"Stanovanje", GF07:"Zdravstvo", GF08:"Kultura i sport",
GF09:"Obrazovanje", GF10:"Socijalna zaštita"
};
const latest = Math.max(...aggregated.map(d => +d.year));
const rows = aggregated
.filter(d => +d.year === latest)
.map(d => ({...d, fn: labels[d.cofog99] || d.cofog99}));
plot = Plot.plot({
style: {background: "transparent", color: "#1C1916"},
marginLeft: 170, marginRight: 30, height: 460,
x: {label: "% BDP-a", grid: true},
y: {label: null, domain: Object.values(labels)},
color: {domain: ["HR","EU27_2020"], range: [verdigris, ochre], legend: true},
marks: [
Plot.barX(rows, {
y: "fn", x: "value", fill: "geo",
fy: null, sort: {y: "x", reverse: true},
dx: 0
}),
Plot.ruleX([0], {stroke: ink})
]
});
} else {
const focusGeos = new Set(["HR", "EU27_2020", peer]);
const rows = aggregated.filter(d => focusGeos.has(d.geo));
plot = Plot.plot({
style: {background: "transparent", color: "#1C1916"},
marginLeft: 50, marginRight: 30, marginBottom: 36, height: 440,
x: {label: null, tickFormat: d => "" + d, grid: false},
y: {label: "% BDP-a", grid: true},
color: {
domain: ["HR","EU27_2020", peer],
range: [verdigris, ink, ochre],
legend: true
},
marks: [
Plot.ruleY([0], {stroke: slate, strokeOpacity: 0.4}),
Plot.lineY(rows, {
x: "year", y: "value", stroke: "geo",
strokeWidth: d => d.geo === "HR" ? 2.6 : 1.5,
curve: "monotone-x"
}),
Plot.dot(rows.filter(d => d.geo === "HR" && d.year === Math.max(...rows.map(r => +r.year))),
{x: "year", y: "value", fill: verdigris, r: 4})
]
});
}
root.appendChild(plot);
return plot;
}