Sve što želite znati o državi i javnim politikama u Hrvatskoj
  • Knjiga
  • Vodič
  • Pojmovnik
  • Alat
  • Podaci
  • Uči uz AI
  • Nastava
    • Predavanja
    • Silabus
    • Tjedni raspored
  • Resursi
  • Upute
  • Twitter
  • Facebook
  • LinkedIn
  1. Appendices
  2. Mreža pojmova
  • Naslovnica
  • Uvod
  • DIO I: KLASIČNA ANALIZA JAVNIH FINANCIJA I POLITIKA
    • Uloga države u gospodarstvu
    • Alokacijska funkcija i tržišni neuspjesi
    • Distribucijska funkcija
    • Stabilizacijska funkcija
    • Klasična teorija oporezivanja
  • DIO II: JAVNI IZBOR – ŠTO, KAKO I ZAŠTO?
    • Što je to javni izbor i zašto nas se tiče?
    • Teorija igara i zakonodavno pregovaranje
    • Kolektivni izbor
    • Političke stranke i izbori
    • Interesne skupine
    • Dva pogleda na birokraciju
  • DIO III: NOVA INSTITUCIONALNA I KONSTITUCIONALNA EKONOMIKA
    • Nova institucionalna ekonomika
    • Konstitucionalna (ustavna) ekonomika
  • DIO IV: JAVNE FINANCIJE I JAVNE POLITIKE U PRAKSI
    • Instrumenti državne intervencije
    • Kako država troši naš novac?
    • Porezni sustavi u praksi i budućnost oporezivanja
    • Javne financije Europske unije
  • DIO V: KAKO DO KVALITETNIJIH JAVNIH POLITIKA?
    • Državni neuspjesi i prepreke kvalitetnim javnim politikama
    • Novi javni menadžment
    • Od novog javnog menadžmenta prema novom upravljanju
    • Cost-benefit analiza i evaluacija javnih politika
    • Reforme u javnom sektoru
  • Appendices
    • Literatura
    • Mreža pojmova
    • Vodič
    • Vodič · Uloga države u gospodarstvu
    • Vodič · Alokacijska funkcija i tržišni neuspjesi
    • Vodič · Distribucijska funkcija
    • Vodič · Stabilizacijska funkcija
    • Vodič · Klasična teorija oporezivanja
    • Vodič · Javni izbor
    • Vodič · Teorija igara
    • Vodič · Kolektivni izbor
    • Vodič · Političke stranke i izbori
    • Vodič · Interesne skupine
    • Vodič · Dva pogleda na birokraciju
    • Vodič · Institucije kao pravila igre
    • Vodič · Konstitucionalna ekonomika
    • Vodič · Instrumenti državne intervencije
    • Vodič · Javna potrošnja
    • Vodič · Porezni sustavi u praksi
    • Vodič · Javne financije Europske unije
    • Vodič · Državni neuspjesi
    • Vodič · Novi javni menadžment
    • Vodič · Novo upravljanje
    • Vodič · CBA i evaluacija
    • Vodič · Reforme u javnom sektoru
    • Država i javne politike u malom
    • Alat / Igralište
    • Resursi
    • Uči uz AI
    • Predavanja
    • Silabus kolegija
    • Tjedni raspored
    • Upute
  1. Appendices
  2. Mreža pojmova

Appendix B — Mreža pojmova

  • Show All Code
  • Hide All Code

  • View Source
Pojmovnik · mreža pojmova knjige

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
graph = FileAttachment("data/concept-graph.json").json()
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();
}
Literatura
Vodič
Source Code
---
title: "Mreža pojmova"
toc: false
number-sections: false
lang: hr-HR
comments: false
format:
  html:
    page-layout: full
    body-classes: pm-page
---

```{=html}
<header class="pm-head">
  <span class="pm-kicker">Pojmovnik · mreža pojmova knjige</span>
  <h1 class="pm-title">Knjiga kao mreža pojmova</h1>
  <p class="pm-lede">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.</p>
</header>

<style>
.pm-page #quarto-document-content { max-width: none; }
.pm-head { max-width: 760px; margin: 0 auto 1.4rem; text-align: center; }
.pm-kicker { font-family: var(--bs-font-sans-serif, "Public Sans", sans-serif);
  text-transform: uppercase; letter-spacing: .14em; font-size: .72rem; font-weight: 600;
  color: #4A6B5C; }
.pm-title { font-family: "Newsreader", Georgia, serif; font-weight: 600;
  font-size: clamp(1.9rem, 4vw, 2.7rem); margin: .35rem 0 .55rem; color: #1C1916; }
.pm-lede { font-family: "Newsreader", Georgia, serif; font-size: 1.05rem; line-height: 1.6;
  color: #4A4036; margin: 0; }

.pm-wrap { display: grid; grid-template-columns: minmax(0,1fr) 320px; gap: 1.1rem;
  align-items: start; max-width: 1320px; margin: 0 auto; }
@media (max-width: 900px) {
  .pm-wrap { grid-template-columns: 1fr; }
  /* niži graf na mobitelu + pan-y da se stranica može skrolati kroz njega */
  .pm-stage svg { height: 56vh; min-height: 340px; touch-action: pan-y; }
}

.pm-controls { display: flex; flex-wrap: wrap; gap: .9rem 1.4rem; align-items: center;
  padding: .7rem .9rem; margin-bottom: .7rem;
  background: #EAE3D4; border: 1px solid rgba(28,25,22,.10); border-radius: 10px;
  font-family: "Public Sans", sans-serif; font-size: .85rem; }
.pm-search { flex: 1 1 230px; min-width: 180px; padding: .5rem .7rem;
  border: 1px solid rgba(28,25,22,.22); border-radius: 8px; background: #F2EDE3;
  font-family: "Public Sans", sans-serif; font-size: .92rem; color: #1C1916; }
.pm-search:focus { outline: 2px solid rgba(74,107,92,.45); border-color: #4A6B5C; }
.pm-field { display: flex; align-items: center; gap: .5rem; color: #4A4036; }
.pm-field input[type=range] { accent-color: #4A6B5C; }
.pm-count { color: #6B6357; font-variant-numeric: tabular-nums; }

.pm-legend { display: flex; flex-wrap: wrap; gap: .4rem .55rem; max-width: 1320px;
  margin: 0 auto .55rem; font-family: "Public Sans", sans-serif; }
.pm-chip { display: inline-flex; align-items: center; gap: .42rem; cursor: pointer;
  padding: .28rem .6rem; border-radius: 999px; font: inherit; font-size: .78rem;
  line-height: 1.2; color: #1C1916; background: #EAE3D4;
  border: 1px solid rgba(28,25,22,.10); user-select: none; transition: opacity .15s; }
.pm-chip.pm-off { opacity: .38; }
.pm-chip:focus-visible { outline: 2px solid #4A6B5C; outline-offset: 2px; }
.pm-chip .pm-dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }

.pm-stage { position: relative; background:
  radial-gradient(120% 120% at 50% 0%, #F6F2E9 0%, #EFE9DC 100%);
  border: 1px solid rgba(28,25,22,.12); border-radius: 12px; overflow: hidden; }
.pm-stage svg { display: block; width: 100%; height: 72vh; min-height: 460px;
  cursor: grab; }
.pm-stage svg:active { cursor: grabbing; }
.pm-node { cursor: pointer; }
.pm-node:focus { outline: none; }
.pm-node:focus-visible { stroke: #1C1916; stroke-width: 3px;
  outline: 3px solid rgba(74,107,92,.65); }
.pm-label { font-family: "Public Sans", sans-serif; pointer-events: none;
  fill: #2A241E; paint-order: stroke; stroke: #F4EFE6; stroke-width: 3px;
  stroke-linejoin: round; }
.pm-hint { position: absolute; left: 12px; bottom: 10px; font-size: .72rem;
  font-family: "Public Sans", sans-serif; color: #6B6357; pointer-events: none; }

.pm-panel { position: sticky; top: 1rem; background: #F4EFE6;
  border: 1px solid rgba(28,25,22,.12); border-radius: 12px; padding: 1.1rem 1.15rem;
  min-height: 200px; font-family: "Public Sans", sans-serif; }
.pm-panel .pm-empty { color: #6B6357; font-style: italic; line-height: 1.55; }
.pm-panel .pm-dio { display: inline-flex; align-items: center; gap: .42rem;
  font-size: .74rem; text-transform: uppercase; letter-spacing: .08em; font-weight: 600;
  color: #4A4036; margin-bottom: .35rem; }
.pm-panel .pm-dio .pm-dot { width: 10px; height: 10px; border-radius: 50%; }
.pm-panel h2 { font-family: "Newsreader", serif; font-size: 1.4rem; font-weight: 600;
  color: #1C1916; margin: 0 0 .5rem; line-height: 1.2; }
.pm-panel .pm-def { font-family: "Newsreader", serif; font-size: 1rem; line-height: 1.6;
  color: #322B23; margin: 0 0 .7rem; }
.pm-panel .pm-meta { font-size: .78rem; color: #6B6357; margin: 0 0 .9rem; }
.pm-panel .pm-neigh { font-size: .8rem; color: #4A4036; line-height: 1.6; margin: 0 0 1rem; }
.pm-panel .pm-neigh b { font-weight: 600; color: #1C1916; }
.pm-open { display: inline-block; font-size: .85rem; font-weight: 600; text-decoration: none;
  color: #F2EDE3; background: #4A6B5C; padding: .5rem .85rem; border-radius: 8px; }
.pm-open:hover { background: #3A5648; color: #fff; }

/* Traka kad se u mrežu uđe s gumba poglavlja (?chapter=<slug>) */
.pm-chapter-banner { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem;
  max-width: 1320px; margin: 0 auto .7rem; padding: .6rem .85rem;
  background: #EAE3D4; border: 1px solid rgba(74,107,92,.35); border-left: 3px solid #4A6B5C;
  border-radius: 10px; font-family: "Public Sans", sans-serif; font-size: .9rem;
  color: #322B23; }
.pm-chapter-name b { color: #1C1916; }
.pm-chapter-clear { font-family: "Public Sans", sans-serif; font-size: .8rem; font-weight: 600;
  color: #4A6B5C; background: transparent; border: 1px solid rgba(74,107,92,.5);
  border-radius: 999px; padding: .3rem .7rem; cursor: pointer; margin-left: auto;
  transition: background .15s, color .15s; }
.pm-chapter-clear:hover { background: #4A6B5C; color: #F2EDE3; }
</style>
```

```{ojs}
//| echo: false
graph = FileAttachment("data/concept-graph.json").json()
```

```{ojs}
//| echo: false
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();
}
```
 

© 2026 Palić, Šikić, Deskar-Škrbić | Javne politike u Hrvatskoj