Appendix B — Mreža pojmova
Knjiga kao mreža pojmova
Svaki čvor je jedan pojam koji knjiga definira. Veličina prati koliko se pojam provlači kroz knjigu, boja označava dio kojem pripada, a veze povezuju pojmove koji se zajedno pojavljuju. Upišite pojam da osvijetlite njegovo susjedstvo ili kliknite čvor za definiciju i poveznicu na poglavlje.
Prikaži kod
pojmovnik = {
// Kategorijska rampa po DIO-u, ugođena za razlikovanje i kod sljepoće na
// boje: susjedni dijelovi se razlikuju i po SVJETLINI, ne samo po tonu
// (uz to su čvorovi tabbabilni s aria-oznakom koja nosi DIO bez boje).
const DIO = {
0: { label: "Uvod", desc: "Uvod", color: "#9C9382" },
1: { label: "DIO I", desc: "Klasične javne financije", color: "#1B2A4A" },
2: { label: "DIO II", desc: "Javni izbor", color: "#4A6B5C" },
3: { label: "DIO III", desc: "Institucije", color: "#C8985E" },
4: { label: "DIO IV", desc: "Politike u praksi", color: "#7C8A97" },
5: { label: "DIO V", desc: "Kvalitetnije politike", color: "#6B1F26" }
};
// --- podaci (klon da ne diramo izvor pri ponovnom izvođenju) -------------
const nodes = graph.nodes.map(d => Object.assign({}, d));
const byId = new Map(nodes.map(d => [d.id, d]));
const allEdges = graph.edges
.filter(e => byId.has(e.source) && byId.has(e.target))
.map(e => ({ source: e.source, target: e.target, weight: e.weight, cooc: e.cooc, type: e.type }));
// susjedstvo (za isticanje i bočni panel) — iz PUNE mreže
const adj = new Map(nodes.map(d => [d.id, new Map()]));
for (const e of allEdges) {
adj.get(e.source).set(e.target, e.weight);
adj.get(e.target).set(e.source, e.weight);
}
// --- top-K orezivanje za čitljiv prikaz ---------------------------------
function prunedEdges(K) {
const inc = new Map(nodes.map(d => [d.id, []]));
for (const e of allEdges) { inc.get(e.source).push(e); inc.get(e.target).push(e); }
const keep = new Set();
for (const [, es] of inc) {
es.sort((a, b) => b.weight - a.weight);
for (const e of es.slice(0, K)) keep.add(e);
}
return [...keep].map(e => ({ source: e.source, target: e.target, weight: e.weight, type: e.type }));
}
// --- stanje -------------------------------------------------------------
const state = { query: "", K: 3, selected: null, focus: null, dios: new Set([0,1,2,3,4,5]),
chapter: null };
const norm = s => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
// Skup čvorova koji pripadaju traženom poglavlju (?chapter=<slug> iz URL-a).
// Slug se uspoređuje s poljem `chapter` oblika "chapters/<slug>.qmd".
function chapterNodeSet(slug) {
if (!slug) return null;
const ids = nodes.filter(d => (d.chapter || "").replace(/^chapters\//, "")
.replace(/\.qmd$/, "") === slug).map(d => d.id);
return ids.length ? new Set(ids) : null;
}
// --- skele (layout) ------------------------------------------------------
const root = d3.create("div");
const legend = root.append("div").attr("class", "pm-legend").attr("role", "group")
.attr("aria-label", "Filtri dijelova knjige");
[1,2,3,4,5].forEach(k => {
const chip = legend.append("button").attr("type", "button").attr("class", "pm-chip")
.attr("data-dio", k).attr("aria-pressed", "true")
.attr("aria-label", `${DIO[k].label} — ${DIO[k].desc} (uključi/isključi)`)
.on("click", () => toggleDio(k));
chip.append("span").attr("class", "pm-dot").style("background", DIO[k].color);
chip.append("span").text(`${DIO[k].label} — ${DIO[k].desc}`);
});
const controls = root.append("div").attr("class", "pm-controls");
const search = controls.append("input").attr("class", "pm-search").attr("type", "search")
.attr("placeholder", "Tražite pojam (npr. eksternalija, renta, institucije)…")
.attr("aria-label", "Tražite pojam u mreži")
.on("input", function () { state.query = this.value; applyView(); });
const densField = controls.append("label").attr("class", "pm-field");
densField.append("span").text("Gustoća veza");
const dens = densField.append("input").attr("type", "range")
.attr("min", 1).attr("max", 8).attr("step", 1).attr("value", state.K)
.on("input", function () { state.K = +this.value; rebuildLinks(); });
const countLbl = controls.append("span").attr("class", "pm-count");
const wrap = root.append("div").attr("class", "pm-wrap");
const stage = wrap.append("div").attr("class", "pm-stage");
const panel = wrap.append("div").attr("class", "pm-panel");
stage.append("div").attr("class", "pm-hint").text("Povucite čvor · kotačić = zum · klik = definicija");
// --- SVG + sile ----------------------------------------------------------
const width = 900, height = 680;
const svg = stage.append("svg").attr("viewBox", [0, 0, width, height])
.attr("preserveAspectRatio", "xMidYMid meet")
.attr("role", "group").attr("aria-label", "Interaktivna mreža pojmova knjige");
const zoomG = svg.append("g");
const linkG = zoomG.append("g").attr("stroke", "#9A8F7C");
const nodeG = zoomG.append("g");
const labelG = zoomG.append("g");
const r = d3.scaleSqrt().domain([1, d3.max(nodes, d => d.mentions)]).range([5, 22]);
// horizontalno grupiranje po DIO-u (meko) — dijelovi se razmaknu slijeva nadesno
const dioX = d3.scalePoint().domain([0,1,2,3,4,5]).range([width*0.12, width*0.88]).padding(0.5);
const sim = d3.forceSimulation(nodes)
.force("charge", d3.forceManyBody().strength(-150))
.force("link", d3.forceLink([]).id(d => d.id).distance(d => 120 - Math.min(d.weight, 8) * 8).strength(0.25))
.force("x", d3.forceX(d => dioX(d.dio)).strength(0.10))
.force("y", d3.forceY(height / 2).strength(0.06))
.force("collide", d3.forceCollide(d => r(d.mentions) + 6))
.on("tick", ticked);
let linkSel = linkG.selectAll("line");
const nodeSel = nodeG.selectAll("circle").data(nodes).join("circle")
.attr("class", "pm-node")
.attr("r", d => r(d.mentions))
.attr("fill", d => DIO[d.dio].color)
.attr("stroke", "#F4EFE6").attr("stroke-width", 1.5)
.attr("tabindex", 0).attr("role", "button")
.attr("aria-label", d => `${d.term}. ${DIO[d.dio].label}, ${DIO[d.dio].desc}. Poglavlje: ${d.chapterTitle}. Spominje se u ${d.mentions} poglavlja. Enter za definiciju.`)
.on("pointerover", (e, d) => { state.focus = d.id; applyView(); })
.on("pointerout", () => { state.focus = null; applyView(); })
.on("focus", (e, d) => { state.focus = d.id; applyView(); })
.on("blur", () => { state.focus = null; applyView(); })
.on("keydown", (e, d) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); state.selected = d.id; renderPanel(); applyView(); } })
.on("click", (e, d) => { state.selected = d.id; renderPanel(); applyView(); })
.call(d3.drag()
.on("start", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; })
.on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
nodeSel.append("title").text(d => `${d.term} · ${DIO[d.dio].label} · spominje se u ${d.mentions} pogl.`);
const labelSel = labelG.selectAll("text").data(nodes).join("text")
.attr("class", "pm-label")
.attr("text-anchor", "middle")
.attr("dy", d => -r(d.mentions) - 4)
.style("font-size", d => `${9 + Math.min(d.mentions, 14) * 0.35}px`)
.text(d => d.term);
svg.call(d3.zoom().scaleExtent([0.3, 5]).on("zoom", e => zoomG.attr("transform", e.transform)));
function ticked() {
linkSel.attr("x1", d => d.source.x).attr("y1", d => d.source.y)
.attr("x2", d => d.target.x).attr("y2", d => d.target.y);
nodeSel.attr("cx", d => d.x).attr("cy", d => d.y);
labelSel.attr("x", d => d.x).attr("y", d => d.y);
}
function rebuildLinks() {
const links = prunedEdges(state.K);
linkSel = linkG.selectAll("line").data(links, d => `${d.source}|${d.target}`)
.join("line").attr("stroke-width", d => Math.max(0.6, Math.min(d.weight, 8) * 0.35));
sim.force("link").links(links);
sim.alpha(0.7).restart();
countLbl.text(`${nodes.length} pojmova · ${links.length} veza`);
applyView();
}
// --- isticanje -----------------------------------------------------------
function applyView() {
const q = norm(state.query);
const focus = state.focus || state.selected;
const inDio = d => state.dios.has(d.dio);
let matches = q ? new Set(nodes.filter(d => norm(d.term).includes(q) || d.id.includes(q)).map(d => d.id)) : null;
// Poglavlje iz URL-a osvjetljava sve svoje pojmove (ako korisnik ne traži drugo).
const chap = (!q && state.chapter) ? chapterNodeSet(state.chapter) : null;
if (chap) matches = chap;
const neigh = focus ? new Set([focus, ...adj.get(focus).keys()]) : null;
const active = d => {
if (!inDio(d)) return false;
if (matches && !matches.has(d.id)) return false;
if (neigh && !neigh.has(d.id)) return false;
return true;
};
nodeSel.attr("opacity", d => !inDio(d) ? 0.05 : (active(d) ? 1 : ((matches || neigh) ? 0.12 : 1)))
.attr("pointer-events", d => inDio(d) ? "all" : "none");
labelSel.attr("opacity", d => {
if (!inDio(d)) return 0;
const hot = (matches && matches.has(d.id)) || (neigh && neigh.has(d.id));
if (hot) return 1;
if (matches || neigh) return 0;
return d.mentions >= 6 ? 0.95 : 0; // mirno stanje: samo glavna čvorišta
});
linkSel.attr("stroke-opacity", d => {
const s = d.source.id || d.source, t = d.target.id || d.target;
const sd = byId.get(s).dio, td = byId.get(t).dio;
if (!state.dios.has(sd) || !state.dios.has(td)) return 0.02;
if (neigh) return (neigh.has(s) && neigh.has(t)) ? 0.55 : 0.03;
if (matches) return (matches.has(s) && matches.has(t)) ? 0.5 : 0.04;
return 0.28;
});
}
function toggleDio(k) {
if (state.dios.has(k)) state.dios.delete(k); else state.dios.add(k);
legend.selectAll(".pm-chip")
.classed("pm-off", function () { return !state.dios.has(+this.dataset.dio); })
.attr("aria-pressed", function () { return state.dios.has(+this.dataset.dio) ? "true" : "false"; });
applyView();
}
// --- bočni panel ---------------------------------------------------------
function renderPanel() {
const d = byId.get(state.selected);
panel.selectAll("*").remove();
if (!d) return;
panel.append("div").attr("class", "pm-dio")
.html(`<span class="pm-dot" style="background:${DIO[d.dio].color}"></span>${DIO[d.dio].label} · ${DIO[d.dio].desc}`);
panel.append("h2").text(d.term);
panel.append("p").attr("class", "pm-def").text(d.firstSentence);
panel.append("p").attr("class", "pm-meta")
.text(`Definira se u poglavlju “${d.chapterTitle}”. Spominje se kroz ${d.mentions} poglavlja.`);
const nb = [...adj.get(d.id).entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
.map(([id]) => byId.get(id).term);
if (nb.length)
panel.append("p").attr("class", "pm-neigh").html(`<b>Povezani pojmovi:</b> ${nb.join(" · ")}`);
const href = d.chapter.replace(/^chapters\//, "chapters/").replace(/\.qmd$/, ".html") + "#def-" + d.id;
panel.append("a").attr("class", "pm-open").attr("href", href).text("Otvori poglavlje →");
}
panel.append("p").attr("class", "pm-empty")
.text("Kliknite na čvor u mreži da ovdje vidite definiciju pojma, povezane pojmove i poveznicu na poglavlje u kojem je uveden.");
// --- ulaz iz poglavlja (?chapter=<slug>) --------------------------------
// Kad korisnik dođe s gumba „Pojmovi ovog poglavlja u mreži”, osvijetli sve
// pojmove tog poglavlja, otvori njegov najistaknutiji pojam u bočnom panelu
// i prikaži traku s nazivom poglavlja te poveznicom za prikaz cijele mreže.
(function initChapterFromUrl() {
let slug = null;
try { slug = new URLSearchParams(location.search).get("chapter"); } catch (e) {}
const set = chapterNodeSet(slug);
if (!set) return;
state.chapter = slug;
const chapNodes = nodes.filter(d => set.has(d.id));
const title = chapNodes[0] ? chapNodes[0].chapterTitle : slug;
const banner = d3.create("div").attr("class", "pm-chapter-banner");
banner.append("span").attr("class", "pm-chapter-name")
.html(`Mreža pojmova poglavlja <b>„${title}”</b> — ${chapNodes.length} pojmova osvijetljeno`);
banner.append("button").attr("type", "button").attr("class", "pm-chapter-clear")
.text("prikaži cijelu mrežu")
.on("click", () => {
state.chapter = null;
banner.remove();
applyView();
});
root.node().insertBefore(banner.node(), root.node().firstChild);
// otvori najistaknutiji pojam poglavlja u bočnom panelu (kontekst + poveznica)
const top = chapNodes.slice().sort((a, b) => b.mentions - a.mentions)[0];
if (top) { state.selected = top.id; renderPanel(); }
applyView();
})();
rebuildLinks();
return root.node();
}