Ion Virgil
  • Prezentare
  • Portofoliu
  • Despre mine
Aplicație interactivă pentru combinațiile de familii de competențe ESCO în grupele majore, submajore, minore și de bază ISCO.
Keywords

ESCO, ISCO, UpSet plot, competențe, D3.js, Observable

← Înapoi la portofoliu

Studiu de caz 55

ESCO · ISCO · intersecții

Ce tipuri de competențe apar împreună?


UpSet înlocuiește cercurile Venn atunci când sunt analizate mai multe tipuri de categorii simultan, în cazul nostru, 8 familii de competențe. Pragul stabilește ponderea minimă pe care trebuie să o aibă o familie de competențe ESCO pentru a fi considerată prezentă într-o grupă.

D3.js UpSet ESCO v1.2.1 ISCO-08

Tipuri de grupe ISCO4
Familii de competențe ESCO8
Grupe de bază426
Filtre4

Sursă: ESCO. Ponderile sunt cele publicate de ESCO; pragul este o alegere exploratorie a utilizatorului.

d3 = require("d3@7")
Plot = require("@observablehq/plot@0.6")
lab = {
  const palette = ["#2563eb", "#22d3ee", "#8b5cf6", "#f97316", "#ef4444", "#14b8a6", "#64748b"];
  const format = new Intl.NumberFormat("ro-RO", {maximumFractionDigits: 1});

  function card(index, title, subtitle) {
    const root = document.createElement("article");
    root.className = "lab-chart-card lab-chart-card--interactive";
    root.dataset.chartType = "interactive";
    root.innerHTML = `<header><span class="lab-chart-index">${String(index).padStart(2, "0")}</span><div><h3>${title}</h3><p>${subtitle}</p></div><span class="lab-live-badge">INTERACTIV</span></header><div class="lab-chart-controls"></div><div class="lab-chart-host"></div>`;
    return {root, controls: root.querySelector(".lab-chart-controls"), host: root.querySelector(".lab-chart-host")};
  }

  function select(label, values, selected = values[0], formatter = value => value) {
    const wrap = document.createElement("label");
    wrap.className = "lab-select";
    const caption = document.createElement("span");
    caption.textContent = label;
    const input = document.createElement("select");
    values.forEach(value => {
      const option = document.createElement("option");
      option.value = value;
      option.textContent = formatter(value);
      option.selected = value === selected;
      input.append(option);
    });
    wrap.append(caption, input);
    return {wrap, input};
  }

  function range(label, min, max, value, step = 1) {
    const wrap = document.createElement("label");
    wrap.className = "lab-range";
    const caption = document.createElement("span");
    const output = document.createElement("strong");
    output.textContent = format.format(value);
    caption.innerHTML = `${label} `;
    caption.append(output);
    const input = document.createElement("input");
    Object.assign(input, {type: "range", min, max, value, step});
    input.addEventListener("input", () => { output.textContent = format.format(+input.value); });
    wrap.append(caption, input);
    return {wrap, input, output};
  }

  function mount(host, node) {
    host.replaceChildren(node);
    const svg = node.matches?.("svg") ? node : node.querySelector?.("svg");
    if (svg) {
      svg.setAttribute("role", "img");
      svg.style.maxWidth = "100%";
      svg.style.height = "auto";
    }
  }

  function label(value) {
    return String(value)
      .replace(/^edu_|^lab_/, "")
      .replaceAll("emp_lfs", "ocupare")
      .replaceAll("act", "activitate")
      .replaceAll("ed5-8", "terțiar")
      .replaceAll("ed3_4", "mediu")
      .replaceAll("ed0-2", "scăzut")
      .replaceAll("_t_", " · total · ")
      .replaceAll("_f_", " · femei · ")
      .replaceAll("_m_", " · bărbați · ")
      .replaceAll("y", "")
      .replaceAll("_", "–");
  }

  return {palette, format, card, select, range, mount, label};
}
taxD3 = require("d3@7")
taxTopojson = require("topojson-client@3")
taxProfiles = taxD3.csv("data/portfolio/esco_isco_profiles.csv", taxD3.autoType)
taxDetailed = taxD3.csv("data/portfolio/esco_isco_detailed.csv", taxD3.autoType)
taxBridge = taxD3.csv("data/portfolio/isced_esco_bridge.csv", taxD3.autoType)
taxMatch = taxD3.csv("data/portfolio/eurostat_field_match.csv", taxD3.autoType)
taxWorld = taxD3.json("data/portfolio/world-countries-110m.json")
taxonomyLab = {
  const d3 = taxD3;
  const colors = ["#2563eb", "#22d3ee", "#8b5cf6", "#f97316", "#ef4444", "#14b8a6", "#84cc16", "#e11d48"];
  const ro = new Intl.NumberFormat("ro-RO", {maximumFractionDigits: 1});
  const integer = new Intl.NumberFormat("ro-RO", {maximumFractionDigits: 0});
  const pct = value => Number.isFinite(value) ? `${ro.format(value * 100)}%` : "—";
  const levelNames = new Map([[1, "Grupe majore"], [2, "Grupe submajore"], [3, "Grupe minore"], [4, "Grupe de bază"]]);
  const majorNames = new Map([...new Map(taxProfiles.map(d => [String(d.major_code), d.major_label_ro]))].entries());
  const skillCatalog = [...new Map(taxProfiles.filter(d => d.isco_level === 1).map(d => [d.skill_code, {code: d.skill_code, label: d.skill_label_ro, raw: d.skill_label}])).values()];
  const skillName = code => skillCatalog.find(d => d.code === code)?.label || code;
  const skillColor = d3.scaleOrdinal(skillCatalog.map(d => d.code), colors);
  const majorColor = d3.scaleOrdinal([...majorNames.keys()], ["#64748b", "#2563eb", "#8b5cf6", "#0891b2", "#0f766e", "#ea580c", "#65a30d", "#dc2626", "#9333ea", "#475569"]);
  const fieldCatalog = [...new Map(taxBridge.map(d => [d.isced_code, {code: d.isced_code, label: d.isced_label_ro}])).values()];

  function shell(meta) {
    const root = document.createElement("section");
    root.className = `top-end-lab top-end-lab--${meta.accent || "violet"}`;
    root.innerHTML = `
      <div class="top-end-command">
        <div><span class="top-end-kicker">${meta.kicker || "ESCO · ISCO · ISCED · D3.JS"}</span><h2>${meta.title}</h2><p>${meta.subtitle}</p></div>
        <span class="top-end-live"><i></i> EXPLORARE LIVE</span>
      </div>
      <div class="top-end-controls" aria-label="Filtrele vizualizării"></div>
      <div class="top-end-metrics" aria-live="polite"></div>
      <div class="top-end-grid"></div>
      <div class="top-end-tooltip" role="status" hidden></div>`;
    const controls = root.querySelector(".top-end-controls");
    const metrics = root.querySelector(".top-end-metrics");
    const grid = root.querySelector(".top-end-grid");
    const tooltip = root.querySelector(".top-end-tooltip");
    const panel = (title, subtitle, wide = false) => {
      const card = document.createElement("article");
      card.className = `top-end-panel${wide ? " top-end-panel--wide" : ""}`;
      card.innerHTML = `<header><div><h3>${title}</h3><p>${subtitle}</p></div><span>D3</span></header><div class="top-end-panel-host"></div>`;
      grid.append(card);
      return card.querySelector(".top-end-panel-host");
    };
    const show = (event, html) => {
      const box = root.getBoundingClientRect();
      tooltip.innerHTML = html;
      tooltip.hidden = false;
      tooltip.style.left = `${Math.max(8, Math.min(event.clientX - box.left + 14, box.width - 240))}px`;
      tooltip.style.top = `${event.clientY - box.top + 14}px`;
    };
    const hide = () => { tooltip.hidden = true; };
    return {root, controls, metrics, panel, show, hide};
  }

  function selectControl(label, values, selected, formatter = value => value) {
    const wrap = document.createElement("label");
    wrap.className = "top-end-control";
    const caption = document.createElement("span");
    caption.textContent = label;
    const input = document.createElement("select");
    values.forEach(value => {
      const option = document.createElement("option");
      option.value = value;
      option.textContent = formatter(value);
      option.selected = String(value) === String(selected);
      input.append(option);
    });
    wrap.append(caption, input);
    return {wrap, input};
  }

  function rangeControl(label, min, max, value, step = 1, formatter = value => value) {
    const wrap = document.createElement("label");
    wrap.className = "top-end-control top-end-control--range";
    const caption = document.createElement("span");
    const output = document.createElement("strong");
    output.textContent = formatter(value);
    caption.textContent = `${label} `;
    caption.append(output);
    const input = document.createElement("input");
    Object.assign(input, {type: "range", min, max, value, step});
    input.addEventListener("input", () => { output.textContent = formatter(+input.value); });
    wrap.append(caption, input);
    return {wrap, input, output};
  }

  function textControl(label, placeholder = "caută…") {
    const wrap = document.createElement("label");
    wrap.className = "top-end-control";
    const caption = document.createElement("span");
    caption.textContent = label;
    const input = document.createElement("input");
    input.type = "search";
    input.placeholder = placeholder;
    input.className = "taxonomy-search";
    wrap.append(caption, input);
    return {wrap, input};
  }

  function setMetrics(host, items) {
    host.innerHTML = items.map(item => `<div><span>${item.label}</span><strong>${item.value}</strong><small>${item.note || ""}</small></div>`).join("");
  }

  function mount(host, node) {
    host.replaceChildren(node);
    const svg = node?.matches?.("svg") ? node : node?.querySelector?.("svg");
    if (svg) {
      svg.setAttribute("role", "img");
      svg.style.maxWidth = "100%";
      svg.style.height = "auto";
    }
  }

  function addMajorOptions(control, includeAll = true) {
    const values = includeAll ? ["all", ...majorNames.keys()] : [...majorNames.keys()];
    control.input.replaceChildren(...values.map(value => {
      const option = document.createElement("option");
      option.value = value;
      option.textContent = value === "all" ? "Toate grupele majore" : `${value} · ${majorNames.get(value)}`;
      return option;
    }));
  }

  function profiles(level, major = "all") {
    const source = taxProfiles.filter(d => d.isco_level === +level && (major === "all" || String(d.major_code) === String(major)));
    return d3.groups(source, d => `${d.isco_code}|${d.isco_label}`).map(([key, rows]) => {
      const [code, label] = key.split("|");
      const values = Object.fromEntries(rows.map(d => [d.skill_code, d.share]));
      const ranked = skillCatalog.map(skill => ({...skill, value: values[skill.code] || 0})).sort((a, b) => b.value - a.value);
      const entropy = -d3.sum(ranked, d => d.value > 0 ? d.value * Math.log(d.value) : 0) / Math.log(skillCatalog.length);
      return {code, label, major: code[0], values, ranked, entropy, descendants: rows[0]?.unit_descendants || 1};
    });
  }

  function controlsRender(ui, render) {
    ui.controls.querySelectorAll("select,input").forEach(input => input.addEventListener(input.type === "range" ? "input" : "change", render));
  }

  function legend(svg, entries, x, y, columns = 2) {
    const item = svg.append("g").attr("transform", `translate(${x},${y})`).selectAll("g").data(entries).join("g")
      .attr("transform", (_, i) => `translate(${(i % columns) * 190},${Math.floor(i / columns) * 22})`);
    item.append("circle").attr("r", 5).attr("fill", d => d.color);
    item.append("text").attr("x", 10).attr("y", 4).attr("font-size", 10).attr("fill", "#475569").text(d => d.label);
  }

  function upset() {
    const ui = shell({title: "Intersecția competențelor ESCO", subtitle: "Un UpSet plot arată exact ce tipuri de competențe ESCO apar împreună în diferite grupe ISCO/ESCO", accent: "violet"});
    const level = selectControl("Nivel ISCO", [1, 2, 3, 4], 3, value => levelNames.get(+value));
    const major = selectControl("Grupă majoră", ["all"], "all"); addMajorOptions(major);
    const threshold = rangeControl("Prag", 5, 35, 14, 1, value => `${value}%`);
    const top = rangeControl("Intersecții", 6, 24, 14, 1, value => integer.format(value));
    ui.controls.append(level.wrap, major.wrap, threshold.wrap, top.wrap);
    const host = ui.panel("Intersecția competențelor ESCO în grupele de bază ISCO", "Barele măsoară câte grupe de ocupații au aceeași familie de competențe; punctele arată tipurile de competențe.", true);
    function render() {
      const data = profiles(+level.input.value, major.input.value);
      const combinations = d3.rollups(data, group => group.length, row => row.ranked.filter(skill => skill.value >= +threshold.input.value / 100).map(skill => skill.code).sort().join("|") || "none")
        .map(([key, count]) => ({key, count, skills: key === "none" ? [] : key.split("|")}))
        .sort((a, b) => b.count - a.count).slice(0, +top.input.value);
      const width = 1080, height = 600, left = 250, topY = 55, barsH = 250, matrixY = 365;
      const x = d3.scaleBand(combinations.map(d => d.key), [left, width - 30]).padding(.25);
      const y = d3.scaleLinear([0, d3.max(combinations, d => d.count) || 1], [topY + barsH, topY]);
      const rowY = d3.scalePoint(skillCatalog.map(d => d.code), [matrixY, height - 45]);
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      svg.append("g").attr("transform", `translate(${left},0)`).call(d3.axisLeft(y).ticks(5)).call(g => g.select(".domain").remove()).call(g => g.selectAll("line").clone().attr("x2", width - left - 30).attr("stroke", "#e2e8f0"));
      svg.append("g").selectAll("rect").data(combinations).join("rect").attr("x", d => x(d.key)).attr("width", x.bandwidth()).attr("y", topY + barsH).attr("height", 0).attr("rx", 5).attr("fill", "#7c3aed")
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.count} grupe ISCO</strong><span>${d.skills.length ? d.skills.map(skillName).join(" + ") : "Nicio familie peste prag"}</span>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${d.count} grupe ISCO</strong><span>${d.skills.map(skillName).join(" + ") || "Sub prag"}</span>`)).on("pointerleave", ui.hide)
        .transition().duration(650).delay((_, i) => i * 25).attr("y", d => y(d.count)).attr("height", d => topY + barsH - y(d.count));
      svg.append("g").selectAll("text").data(skillCatalog).join("text").attr("x", left - 16).attr("y", d => rowY(d.code) + 4).attr("text-anchor", "end").attr("font-size", 10).attr("fill", "#475569").text(d => d.label);
      const columns = svg.append("g").selectAll("g").data(combinations).join("g");
      columns.each(function(combo) {
        const g = d3.select(this);
        const active = combo.skills.map(code => rowY(code));
        if (active.length > 1) g.append("line").attr("x1", x(combo.key) + x.bandwidth() / 2).attr("x2", x(combo.key) + x.bandwidth() / 2).attr("y1", d3.min(active)).attr("y2", d3.max(active)).attr("stroke", "#0f172a").attr("stroke-width", 2);
        g.selectAll("circle").data(skillCatalog).join("circle").attr("cx", x(combo.key) + x.bandwidth() / 2).attr("cy", d => rowY(d.code)).attr("r", d => combo.skills.includes(d.code) ? 6 : 3.5).attr("fill", d => combo.skills.includes(d.code) ? skillColor(d.code) : "#e2e8f0");
      });
      svg.append("text").attr("x", 24).attr("y", 28).attr("font-size", 12).attr("font-weight", 800).attr("fill", "#64748b").text("FRECVENȚA INTERSECȚIEI");
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Grupe analizate", value: integer.format(data.length), note: levelNames.get(+level.input.value)}, {label: "Intersecții distincte", value: integer.format(new Set(data.map(row => row.ranked.filter(skill => skill.value >= +threshold.input.value / 100).map(skill => skill.code).sort().join("|"))).size), note: `prag ${threshold.input.value}%`}, {label: "Cea mai frecventă", value: combinations[0]?.count || 0, note: "grupe cu aceeași semnătură"}, {label: "Categorii mediane", value: ro.format(d3.median(data, row => row.ranked.filter(skill => skill.value >= +threshold.input.value / 100).length) || 0), note: "peste prag"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function marimekko() {
    const ui = shell({title: "Arhitectura proporțională a ocupațiilor", subtitle: "Lățimea reprezintă numărul grupelor de bază din ramură, iar înălțimea arată compoziția competențelor ESCO.", accent: "orange"});
    const level = selectControl("Nivel ISCO", [1, 2, 3], 2, value => levelNames.get(+value));
    const major = selectControl("Grupă majoră", ["all"], "all"); addMajorOptions(major);
    const sort = selectControl("Ordine", ["code", "dominant", "size"], "code", value => ({code: "Cod ISCO", dominant: "Competența dominantă", size: "Dimensiunea ramurii"}[value]));
    const focus = selectControl("Accent ESCO", ["all", ...skillCatalog.map(d => d.code)], "all", value => value === "all" ? "Toate competențele" : skillName(value));
    ui.controls.append(level.wrap, major.wrap, sort.wrap, focus.wrap);
    const host = ui.panel("Marimekko ierarhic", "Fiecare coloană este o grupă ISCO; aria fiecărei celule combină structură și profil.", true);
    function render() {
      let data = profiles(+level.input.value, major.input.value);
      if (sort.input.value === "dominant") data.sort((a, b) => d3.ascending(a.ranked[0].code, b.ranked[0].code) || b.ranked[0].value - a.ranked[0].value);
      else if (sort.input.value === "size") data.sort((a, b) => b.descendants - a.descendants);
      else data.sort((a, b) => d3.ascending(a.code, b.code));
      const width = 1120, height = 610, margin = {top: 35, right: 30, bottom: 100, left: 42};
      const usableW = width - margin.left - margin.right, usableH = height - margin.top - margin.bottom;
      const totalWeight = d3.sum(data, d => d.descendants) || 1;
      let cursor = margin.left;
      const cells = [];
      data.forEach(row => {
        const w = usableW * row.descendants / totalWeight;
        let y = margin.top + usableH;
        skillCatalog.forEach(skill => {
          const h = usableH * (row.values[skill.code] || 0);
          y -= h;
          cells.push({row, skill, x: cursor, y, width: w, height: h, value: row.values[skill.code] || 0});
        });
        cursor += w;
      });
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      svg.append("g").selectAll("rect").data(cells).join("rect").attr("x", d => d.x + .5).attr("width", d => Math.max(0, d.width - 1)).attr("y", margin.top + usableH).attr("height", 0).attr("fill", d => skillColor(d.skill.code)).attr("opacity", d => focus.input.value === "all" || focus.input.value === d.skill.code ? .9 : .13).attr("stroke", "#fff").attr("stroke-width", .7)
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.row.code} · ${d.row.label}</strong><span>${d.skill.label}</span><b>${pct(d.value)}</b>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${d.row.code}</strong><span>${d.skill.label}</span><b>${pct(d.value)}</b>`)).on("pointerleave", ui.hide)
        .transition().duration(700).delay((_, i) => Math.min(400, i * 2)).attr("y", d => d.y).attr("height", d => Math.max(0, d.height));
      const labels = data.filter(row => usableW * row.descendants / totalWeight > 16);
      cursor = margin.left;
      const labelData = data.map(row => { const w = usableW * row.descendants / totalWeight; const result = {...row, x: cursor + w / 2, w}; cursor += w; return result; }).filter(d => d.w > 16);
      svg.append("g").selectAll("text").data(labelData).join("text").attr("transform", d => `translate(${d.x},${height - margin.bottom + 12}) rotate(60)`).attr("font-size", 9).attr("fill", "#64748b").text(d => d.code);
      const axis = d3.scaleLinear([0, 1], [margin.top + usableH, margin.top]);
      svg.append("g").attr("transform", `translate(${margin.left},0)`).call(d3.axisLeft(axis).ticks(5).tickFormat(d3.format(".0%"))).call(g => g.select(".domain").remove());
      legend(svg, skillCatalog.map(d => ({label: d.label, color: skillColor(d.code)})), 70, height - 72, 4);
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Coloane ISCO", value: integer.format(data.length), note: levelNames.get(+level.input.value)}, {label: "Grupe de bază acoperite", value: integer.format(d3.sum(data, d => d.descendants)), note: "lățimea totală"}, {label: "Profil dominant", value: data.length ? skillName(d3.greatest(data.flatMap(d => d.ranked.slice(0, 1)), d => d.value)?.code) : "—", note: "cea mai mare pondere"}, {label: "Diversitate medie", value: pct(d3.mean(data, d => d.entropy) || 0), note: "entropie normalizată"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function ternary() {
    const ui = shell({title: "Compasul ternar al competențelor", subtitle: "Trei familii ESCO sunt comparate simultan; fiecare grupă ISCO ocupă o poziție bazată pe echilibrul relativ dintre ele.", accent: "cyan"});
    const level = selectControl("Nivel ISCO", [2, 3, 4], 2, value => levelNames.get(+value));
    const major = selectControl("Grupă majoră", ["all"], "all"); addMajorOptions(major);
    const a = selectControl("Vârf A", skillCatalog.map(d => d.code), skillCatalog[7].code, skillName);
    const b = selectControl("Vârf B", skillCatalog.map(d => d.code), skillCatalog[1].code, skillName);
    const c = selectControl("Vârf C", skillCatalog.map(d => d.code), skillCatalog[4].code, skillName);
    const minShare = rangeControl("Acoperire minimă", 5, 70, 20, 1, value => `${value}%`);
    ui.controls.append(level.wrap, major.wrap, a.wrap, b.wrap, c.wrap, minShare.wrap);
    const host = ui.panel("Spațiul barycentric", "Apropierea de un vârf indică o pondere relativă mai mare a familiei respective.", true);
    function render() {
      const chosen = [a.input.value, b.input.value, c.input.value];
      if (new Set(chosen).size < 3) return;
      const raw = profiles(+level.input.value, major.input.value);
      const data = raw.map(row => {
        const vals = chosen.map(code => row.values[code] || 0), sum = d3.sum(vals);
        return {...row, vals, sum, normalized: vals.map(value => sum ? value / sum : 0)};
      }).filter(row => row.sum >= +minShare.input.value / 100);
      const width = 1080, height = 700, vertices = [{x: 540, y: 58}, {x: 105, y: 620}, {x: 975, y: 620}];
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      svg.append("path").attr("d", `M${vertices[0].x},${vertices[0].y}L${vertices[1].x},${vertices[1].y}L${vertices[2].x},${vertices[2].y}Z`).attr("fill", "#f8fafc").attr("stroke", "#94a3b8").attr("stroke-width", 2);
      d3.range(.1, 1, .1).forEach(t => {
        [[0,1,2],[1,0,2],[2,0,1]].forEach(([fixed,p,q]) => {
          const start = {x: vertices[fixed].x * t + vertices[p].x * (1-t), y: vertices[fixed].y * t + vertices[p].y * (1-t)};
          const end = {x: vertices[fixed].x * t + vertices[q].x * (1-t), y: vertices[fixed].y * t + vertices[q].y * (1-t)};
          svg.append("line").attr("x1", start.x).attr("y1", start.y).attr("x2", end.x).attr("y2", end.y).attr("stroke", "#e2e8f0");
        });
      });
      const point = row => ({x: d3.sum(row.normalized.map((value, i) => value * vertices[i].x)), y: d3.sum(row.normalized.map((value, i) => value * vertices[i].y))});
      const points = data.map(row => ({...row, ...point(row)}));
      svg.append("g").selectAll("circle").data(points).join("circle").attr("cx", d => d.x).attr("cy", d => d.y).attr("r", 0).attr("fill", d => majorColor(d.major)).attr("fill-opacity", .72).attr("stroke", "#fff").attr("stroke-width", 1.2)
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.code} · ${d.label}</strong><span>${chosen.map((code, i) => `${skillName(code)}: ${pct(d.normalized[i])}`).join("<br>")}</span><b>acoperire ${pct(d.sum)}</b>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${d.code}</strong><span>${d.label}</span><b>${pct(d.sum)}</b>`)).on("pointerleave", ui.hide)
        .transition().duration(650).delay((_, i) => Math.min(500, i * 6)).attr("r", d => 4 + d.entropy * 5);
      vertices.forEach((vertex, i) => svg.append("text").attr("x", vertex.x).attr("y", vertex.y + (i === 0 ? -18 : 28)).attr("text-anchor", "middle").attr("font-size", 13).attr("font-weight", 850).attr("fill", skillColor(chosen[i])).text(skillName(chosen[i])));
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Grupe eligibile", value: integer.format(data.length), note: `din ${raw.length}`}, {label: "Acoperire medie", value: pct(d3.mean(data, d => d.sum) || 0), note: "cele trei familii"}, {label: "Echilibru mediu", value: pct(d3.mean(data, d => d.entropy) || 0), note: "diversitate profil"}, {label: "Nivel", value: level.input.value, note: levelNames.get(+level.input.value)}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function edgeBundle() {
    const ui = shell({title: "Fasciculul ierarhic ocupații–competențe", subtitle: "Legăturile dintre grupele ISCO și competențele ESCO sunt curbate prin ierarhie pentru a reduce aglomerarea vizuală.", accent: "violet"});
    const level = selectControl("Nivel ISCO", [2, 3, 4], 3, value => levelNames.get(+value));
    const major = selectControl("Grupă majoră", ["all"], "2"); addMajorOptions(major);
    major.input.value = "2";
    const count = rangeControl("Ocupații", 8, 34, 22, 1, value => integer.format(value));
    const threshold = rangeControl("Prag legătură", 1, 12, 3, .5, value => `${ro.format(value)}%`);
    const tension = rangeControl("Tensiune", 20, 100, 82, 1, value => `${value}%`);
    ui.controls.append(level.wrap, major.wrap, count.wrap, threshold.wrap, tension.wrap);
    const host = ui.panel("Hierarchical edge bundling", "Trece cu cursorul peste o legătură pentru a izola traseul complet.", true);
    function render() {
      const candidates = [...new Map(taxDetailed.filter(d => d.isco_level === +level.input.value && (major.input.value === "all" || String(d.major_code) === major.input.value)).map(d => [d.isco_code, d.isco_label])).entries()].sort(d3.ascending).slice(0, +count.input.value);
      const allowed = new Set(candidates.map(d => d[0]));
      const edges = taxDetailed.filter(d => allowed.has(d.isco_code) && d.share >= +threshold.input.value / 100 && d.skill_rank <= 8).slice(0, 220);
      const skillLabels = [...new Map(edges.map(d => [d.skill_code, d.skill_label])).entries()];
      const occByMajor = d3.groups(candidates, d => d[0][0]).map(([code, rows]) => ({name: `ISCO ${code}`, type: "branch", children: rows.map(([id, label]) => ({name: id, label, id: `o:${id}`, type: "occupation"}))}));
      const treeData = {name: "root", children: [{name: "Ocupații", children: occByMajor}, {name: "Competențe ESCO", children: skillLabels.map(([id, label]) => ({name: label, label, id: `s:${id}`, skill: id, type: "skill"}))}]};
      const root = d3.hierarchy(treeData);
      d3.cluster().size([2 * Math.PI, 390])(root);
      const leaves = new Map(root.leaves().map(d => [d.data.id, d]));
      const links = edges.map(edge => ({source: leaves.get(`o:${edge.isco_code}`), target: leaves.get(`s:${edge.skill_code}`), edge})).filter(d => d.source && d.target);
      const width = 1100, height = 920, radius = 390;
      const svg = d3.create("svg").attr("viewBox", [-width / 2, -height / 2, width, height]);
      const line = d3.lineRadial().curve(d3.curveBundle.beta(+tension.input.value / 100)).radius(d => d.y).angle(d => d.x);
      const paths = svg.append("g").attr("fill", "none").selectAll("path").data(links).join("path").attr("d", d => line(d.source.path(d.target))).attr("stroke", d => skillColor(d.edge.skill_code)).attr("stroke-opacity", .18).attr("stroke-width", d => 1 + d.edge.share * 15)
        .on("pointerenter", function(event, d) { paths.attr("stroke-opacity", e => e === d ? .95 : .035); d3.select(this).raise(); ui.show(event, `<strong>${d.edge.isco_code} · ${d.edge.isco_label}</strong><span>${d.edge.skill_label}</span><b>${pct(d.edge.share)}</b>`); }).on("pointermove", (event, d) => ui.show(event, `<strong>${d.edge.isco_code}</strong><span>${d.edge.skill_label}</span><b>${pct(d.edge.share)}</b>`)).on("pointerleave", () => { paths.attr("stroke-opacity", .18); ui.hide(); });
      const labels = svg.append("g").selectAll("g").data(root.leaves()).join("g").attr("transform", d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y + 8},0)`);
      labels.append("circle").attr("r", 3.5).attr("fill", d => d.data.type === "skill" ? "#8b5cf6" : majorColor(d.data.name[0]));
      labels.append("text").attr("x", d => d.x < Math.PI ? 7 : -7).attr("dy", ".32em").attr("text-anchor", d => d.x < Math.PI ? "start" : "end").attr("transform", d => d.x >= Math.PI ? "rotate(180)" : null).attr("font-size", 8.5).attr("fill", "#475569").text(d => d.data.type === "skill" ? d.data.label.slice(0, 32) : d.data.name);
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Noduri ocupaționale", value: integer.format(candidates.length), note: levelNames.get(+level.input.value)}, {label: "Competențe conectate", value: integer.format(skillLabels.length), note: "grupe ESCO detaliate"}, {label: "Legături vizibile", value: integer.format(links.length), note: `≥ ${threshold.input.value}%`}, {label: "Tensiune fascicul", value: `${tension.input.value}%`, note: "curbă bundle"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function radviz() {
    const ui = shell({title: "RadViz pentru profilurile ocupaționale", subtitle: "Cele opt familii ESCO acționează ca ancore; fiecare grupă ISCO este atrasă proporțional de profilul său de competențe.", accent: "cyan"});
    const level = selectControl("Nivel ISCO", [2, 3, 4], 3, value => levelNames.get(+value));
    const major = selectControl("Grupă majoră", ["all"], "all"); addMajorOptions(major);
    const exponent = rangeControl("Contrast", 40, 220, 100, 5, value => `${ro.format(value / 100)}×`);
    const concentration = rangeControl("Dominanță minimă", 10, 50, 10, 1, value => `${value}%`);
    const colorMode = selectControl("Culoare", ["skill", "major"], "skill", value => value === "skill" ? "Competență dominantă" : "Grupă ISCO majoră");
    ui.controls.append(level.wrap, major.wrap, exponent.wrap, concentration.wrap, colorMode.wrap);
    const host = ui.panel("Proiecție RadViz", "Poziția rezultă din centrul de greutate al celor opt ancore.", true);
    function render() {
      const data = profiles(+level.input.value, major.input.value).filter(row => row.ranked[0].value >= +concentration.input.value / 100);
      const width = 1040, height = 760, center = {x: 520, y: 380}, radius = 300;
      const anchors = skillCatalog.map((skill, i) => ({...skill, angle: -Math.PI / 2 + i * 2 * Math.PI / skillCatalog.length, x: center.x + Math.cos(-Math.PI / 2 + i * 2 * Math.PI / skillCatalog.length) * radius, y: center.y + Math.sin(-Math.PI / 2 + i * 2 * Math.PI / skillCatalog.length) * radius}));
      const power = +exponent.input.value / 100;
      const points = data.map(row => {
        const weights = anchors.map(anchor => Math.pow(row.values[anchor.code] || 0, power));
        const total = d3.sum(weights) || 1;
        return {...row, x: d3.sum(anchors.map((anchor, i) => anchor.x * weights[i])) / total, y: d3.sum(anchors.map((anchor, i) => anchor.y * weights[i])) / total};
      });
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      svg.append("circle").attr("cx", center.x).attr("cy", center.y).attr("r", radius).attr("fill", "#f8fafc").attr("stroke", "#cbd5e1").attr("stroke-width", 2);
      anchors.forEach(anchor => {
        svg.append("line").attr("x1", center.x).attr("y1", center.y).attr("x2", anchor.x).attr("y2", anchor.y).attr("stroke", "#e2e8f0");
        svg.append("circle").attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 8).attr("fill", skillColor(anchor.code));
        svg.append("text").attr("x", center.x + Math.cos(anchor.angle) * (radius + 28)).attr("y", center.y + Math.sin(anchor.angle) * (radius + 28)).attr("text-anchor", Math.cos(anchor.angle) > .2 ? "start" : Math.cos(anchor.angle) < -.2 ? "end" : "middle").attr("font-size", 10).attr("font-weight", 750).attr("fill", "#475569").text(anchor.label.slice(0, 32));
      });
      svg.append("g").selectAll("circle").data(points).join("circle").attr("cx", center.x).attr("cy", center.y).attr("r", 0).attr("fill", d => colorMode.input.value === "skill" ? skillColor(d.ranked[0].code) : majorColor(d.major)).attr("fill-opacity", .7).attr("stroke", "#fff").attr("stroke-width", 1.2)
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.code} · ${d.label}</strong><span>Dominant: ${d.ranked[0].label}</span><b>${pct(d.ranked[0].value)}</b>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${d.code}</strong><span>${d.ranked[0].label}</span><b>${pct(d.ranked[0].value)}</b>`)).on("pointerleave", ui.hide)
        .transition().duration(750).delay((_, i) => Math.min(550, i * 4)).attr("cx", d => d.x).attr("cy", d => d.y).attr("r", d => 3.5 + d.entropy * 4);
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Puncte", value: integer.format(points.length), note: "profiluri ISCO"}, {label: "Entropie medie", value: pct(d3.mean(points, d => d.entropy) || 0), note: "diversitate"}, {label: "Dominanță medie", value: pct(d3.mean(points, d => d.ranked[0].value) || 0), note: "cea mai mare familie"}, {label: "Contrast", value: `${ro.format(power)}×`, note: "forța ancorelor"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function hilbert() {
    const ui = shell({title: "Genomul Hilbert al ocupațiilor", subtitle: "Cele 426 grupe de bază ISCO sunt ordonate pe o curbă care păstrează vecinătatea și comprimă taxonomia într-un atlas pătrat.", accent: "orange"});
    const major = selectControl("Grupă majoră", ["all"], "all"); addMajorOptions(major);
    const focus = selectControl("Familie ESCO", ["all", ...skillCatalog.map(d => d.code)], "all", value => value === "all" ? "Toate familiile" : skillName(value));
    const dominance = rangeControl("Dominanță minimă", 10, 55, 10, 1, value => `${value}%`);
    const gap = rangeControl("Spațiere", 0, 4, 1, .5, value => ro.format(value));
    ui.controls.append(major.wrap, focus.wrap, dominance.wrap, gap.wrap);
    const host = ui.panel("Atlas Hilbert", "Culoarea arată familia dominantă, iar opacitatea arată intensitatea dominanței.", true);
    function hilbertXY(n, index) {
      let x = 0, y = 0, t = index;
      for (let s = 1; s < n; s *= 2) {
        const rx = 1 & Math.floor(t / 2), ry = 1 & (t ^ rx);
        if (ry === 0) { if (rx === 1) { x = s - 1 - x; y = s - 1 - y; } [x, y] = [y, x]; }
        x += s * rx; y += s * ry; t = Math.floor(t / 4);
      }
      return [x, y];
    }
    function render() {
      let data = profiles(4, major.input.value).sort((a, b) => d3.ascending(a.code, b.code));
      data = data.filter(row => row.ranked[0].value >= +dominance.input.value / 100 && (focus.input.value === "all" || row.ranked[0].code === focus.input.value));
      const n = 32, cell = 19, width = 760, height = 700, originX = 78, originY = 42;
      const points = data.map((row, i) => ({...row, index: i, xy: hilbertXY(n, i)}));
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      if (points.length > 1) svg.append("path").datum(points).attr("d", d3.line().x(d => originX + d.xy[0] * cell + cell / 2).y(d => originY + d.xy[1] * cell + cell / 2)).attr("fill", "none").attr("stroke", "#cbd5e1").attr("stroke-width", 1);
      svg.append("g").selectAll("rect").data(points).join("rect").attr("x", d => originX + d.xy[0] * cell + +gap.input.value / 2).attr("y", d => originY + d.xy[1] * cell + +gap.input.value / 2).attr("width", 0).attr("height", 0).attr("rx", 2).attr("fill", d => skillColor(d.ranked[0].code)).attr("fill-opacity", d => .28 + d.ranked[0].value * 1.25).attr("stroke", "#fff")
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.code} · ${d.label}</strong><span>${d.ranked[0].label}</span><b>${pct(d.ranked[0].value)}</b>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${d.code}</strong><span>${d.ranked[0].label}</span><b>${pct(d.ranked[0].value)}</b>`)).on("pointerleave", ui.hide)
        .transition().duration(650).delay((_, i) => Math.min(500, i * 2)).attr("width", cell - +gap.input.value).attr("height", cell - +gap.input.value);
      legend(svg, skillCatalog.map(d => ({label: d.label, color: skillColor(d.code)})), 90, 655, 4);
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Celule vizibile", value: integer.format(points.length), note: "grupe de bază"}, {label: "Capacitate grilă", value: integer.format(n * n), note: "curbă de ordin 5"}, {label: "Dominanță medie", value: pct(d3.mean(points, d => d.ranked[0].value) || 0), note: "familia principală"}, {label: "Ramuri majore", value: integer.format(new Set(points.map(d => d.major)).size), note: "acoperire ISCO"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function tableLens() {
    const ui = shell({title: "Table Lens pentru 426 de profiluri", subtitle: "O lentilă focus+context mărește rândul investigat și păstrează simultan structura tuturor profilurilor ESCO.", accent: "cyan"});
    const level = selectControl("Nivel ISCO", [3, 4], 4, value => levelNames.get(+value));
    const major = selectControl("Grupă majoră", ["all"], "2"); addMajorOptions(major); major.input.value = "2";
    const sortSkill = selectControl("Ordonează după", ["code", ...skillCatalog.map(d => d.code)], "code", value => value === "code" ? "Cod ISCO" : skillName(value));
    const limit = rangeControl("Rânduri", 30, 180, 100, 5, value => integer.format(value));
    const magnify = rangeControl("Magnificare", 2, 12, 7, 1, value => `${value}×`);
    const search = textControl("Caută ocupație", "cod sau denumire");
    ui.controls.append(level.wrap, major.wrap, sortSkill.wrap, limit.wrap, magnify.wrap, search.wrap);
    const host = ui.panel("Table Lens", "Mișcă pointerul vertical: rândul focal se extinde, iar barele celulare devin lizibile.", true);
    function render() {
      let data = profiles(+level.input.value, major.input.value);
      const query = search.input.value.trim().toLowerCase();
      if (query) data = data.filter(row => row.code.includes(query) || row.label.toLowerCase().includes(query));
      if (sortSkill.input.value === "code") data.sort((a, b) => d3.ascending(a.code, b.code));
      else data.sort((a, b) => (b.values[sortSkill.input.value] || 0) - (a.values[sortSkill.input.value] || 0));
      data = data.slice(0, +limit.input.value);
      const width = 1160, height = 720, labelW = 250, top = 58, bottom = 30, colW = (width - labelW - 25) / skillCatalog.length;
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]).style("cursor", "ns-resize");
      skillCatalog.forEach((skill, i) => svg.append("text").attr("transform", `translate(${labelW + i * colW + colW / 2},${top - 10}) rotate(-35)`).attr("text-anchor", "start").attr("font-size", 9).attr("fill", "#475569").text(skill.label.slice(0, 24)));
      const rowsG = svg.append("g");
      function draw(focusIndex = Math.floor(data.length / 2)) {
        const base = 1, lens = +magnify.input.value;
        const weights = data.map((_, i) => base + (lens - base) * Math.exp(-Math.pow((i - focusIndex) / 1.7, 2)));
        const usable = height - top - bottom, scale = usable / (d3.sum(weights) || 1);
        let y = top;
        const layout = data.map((row, i) => { const h = weights[i] * scale; const item = {...row, y, h, focus: Math.abs(i-focusIndex) <= 1}; y += h; return item; });
        const groups = rowsG.selectAll("g.tax-lens-row").data(layout, d => d.code).join(enter => {
          const g = enter.append("g").attr("class", "tax-lens-row");
          g.append("text").attr("class", "tax-lens-label");
          g.selectAll("rect.tax-cell-bg").data(skillCatalog).join("rect").attr("class", "tax-cell-bg");
          g.selectAll("rect.tax-cell-bar").data(skillCatalog).join("rect").attr("class", "tax-cell-bar");
          return g;
        });
        groups.attr("transform", d => `translate(0,${d.y})`);
        groups.select("text.tax-lens-label").attr("x", labelW - 10).attr("y", d => d.h / 2 + 3).attr("text-anchor", "end").attr("font-size", d => d.focus ? 10.5 : 6.5).attr("font-weight", d => d.focus ? 800 : 500).attr("fill", d => d.focus ? "#0f172a" : "#94a3b8").text(d => d.focus ? `${d.code} · ${d.label.slice(0, 34)}` : d.code);
        groups.each(function(row) {
          const g = d3.select(this);
          g.selectAll("rect.tax-cell-bg").data(skillCatalog).attr("x", (_, i) => labelW + i * colW).attr("y", 1).attr("width", colW - 3).attr("height", Math.max(1, row.h - 2)).attr("fill", "#f1f5f9");
          g.selectAll("rect.tax-cell-bar").data(skillCatalog).attr("x", (_, i) => labelW + i * colW).attr("y", 1).attr("width", skill => (colW - 3) * Math.min(1, (row.values[skill.code] || 0) / .55)).attr("height", Math.max(1, row.h - 2)).attr("fill", skill => skillColor(skill.code)).attr("fill-opacity", row.focus ? .9 : .45)
            .on("pointerenter", (event, skill) => ui.show(event, `<strong>${row.code} · ${row.label}</strong><span>${skill.label}</span><b>${pct(row.values[skill.code] || 0)}</b>`)).on("pointermove", (event, skill) => ui.show(event, `<strong>${row.code}</strong><span>${skill.label}</span><b>${pct(row.values[skill.code] || 0)}</b>`)).on("pointerleave", ui.hide);
        });
      }
      svg.on("pointermove", event => { const [, y] = d3.pointer(event); const index = Math.max(0, Math.min(data.length - 1, Math.round((y - top) / Math.max(1, height - top - bottom) * data.length))); draw(index); }).on("pointerleave", () => draw(Math.floor(data.length / 2)));
      draw(); mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Rânduri", value: integer.format(data.length), note: levelNames.get(+level.input.value)}, {label: "Coloane ESCO", value: skillCatalog.length, note: "familii de competențe"}, {label: "Celule inspectabile", value: integer.format(data.length * skillCatalog.length), note: "focus + context"}, {label: "Magnificare", value: `${magnify.input.value}×`, note: "în zona focală"}]);
    }
    ui.controls.querySelectorAll("select,input:not([type=search])").forEach(input => input.addEventListener(input.type === "range" ? "input" : "change", render));
    search.input.addEventListener("input", render); render(); return ui.root;
  }

  function ridgeline() {
    const ui = shell({title: "Relieful distribuțiilor de competențe", subtitle: "Ridgeline-urile compară distribuția ponderii unei familii ESCO între cele zece grupe majore ISCO.", accent: "orange"});
    const skill = selectControl("Familie ESCO", skillCatalog.map(d => d.code), skillCatalog[1].code, skillName);
    const level = selectControl("Nivel ISCO", [3, 4], 4, value => levelNames.get(+value));
    const bandwidth = rangeControl("Bandwidth", 1, 12, 4, .5, value => `${ro.format(value)} pp`);
    const overlap = rangeControl("Suprapunere", 50, 170, 110, 5, value => `${value}%`);
    ui.controls.append(skill.wrap, level.wrap, bandwidth.wrap, overlap.wrap);
    const host = ui.panel("Ridgeline density", "Aria fiecărei creste este normalizată; lățimea și poziția descriu distribuția.", true);
    function render() {
      const data = profiles(+level.input.value, "all");
      const groups = d3.groups(data, d => d.major).sort((a, b) => d3.ascending(a[0], b[0]));
      const values = data.map(d => d.values[skill.input.value] || 0), maxX = Math.max(.35, d3.max(values) || .35);
      const samples = d3.range(0, maxX + .005, maxX / 90), bw = +bandwidth.input.value / 100;
      const kernel = u => Math.abs(u /= bw) <= 1 ? .75 * (1 - u * u) / bw : 0;
      const densities = groups.map(([major, rows]) => ({major, values: rows.map(d => d.values[skill.input.value] || 0), density: samples.map(x => [x, d3.mean(rows, d => kernel(x - (d.values[skill.input.value] || 0))) || 0])}));
      const width = 1080, height = 720, margin = {top: 35, right: 35, bottom: 55, left: 230};
      const x = d3.scaleLinear([0, maxX], [margin.left, width - margin.right]);
      const y = d3.scalePoint(densities.map(d => d.major), [margin.top + 35, height - margin.bottom]).padding(.5);
      const amplitude = 62 * +overlap.input.value / 100;
      const peak = d3.max(densities.flatMap(d => d.density.map(v => v[1]))) || 1;
      const area = d3.area().curve(d3.curveBasis).x(d => x(d[0])).y0(0).y1(d => -d[1] / peak * amplitude);
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      const ridge = svg.append("g").selectAll("g").data(densities).join("g").attr("transform", d => `translate(0,${y(d.major)})`);
      ridge.append("line").attr("x1", margin.left).attr("x2", width - margin.right).attr("stroke", "#cbd5e1");
      ridge.append("path").attr("d", d => area(d.density)).attr("fill", d => majorColor(d.major)).attr("fill-opacity", .62).attr("stroke", d => majorColor(d.major)).attr("stroke-width", 1.5)
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.major} · ${majorNames.get(d.major)}</strong><span>${skillName(skill.input.value)}</span><b>mediană ${pct(d3.median(d.values))}</b>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${majorNames.get(d.major)}</strong><b>${pct(d3.median(d.values))}</b>`)).on("pointerleave", ui.hide);
      ridge.append("text").attr("x", margin.left - 12).attr("y", 4).attr("text-anchor", "end").attr("font-size", 11).attr("font-weight", 750).attr("fill", "#475569").text(d => `${d.major} · ${majorNames.get(d.major)}`);
      svg.append("g").attr("transform", `translate(0,${height - margin.bottom})`).call(d3.axisBottom(x).tickFormat(d3.format(".0%"))).call(g => g.select(".domain").remove());
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Profiluri", value: integer.format(data.length), note: levelNames.get(+level.input.value)}, {label: "Mediană globală", value: pct(d3.median(values) || 0), note: skillName(skill.input.value)}, {label: "P90", value: pct(d3.quantile(values.sort(d3.ascending), .9) || 0), note: "percentila 90"}, {label: "Bandwidth", value: `${bandwidth.input.value} pp`, note: "netezirea densității"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function bridgeArc() {
    const ui = shell({title: "Puntea magnetică ISCED–ESCO", subtitle: "Un arc diagram conectează domeniile educaționale ISCED-F cu familiile ESCO printr-un crosswalk analitic transparent.", accent: "violet"});
    const field = selectControl("Domeniu ISCED", ["all", ...fieldCatalog.map(d => d.code)], "all", value => value === "all" ? "Toate domeniile" : fieldCatalog.find(d => d.code === value)?.label);
    const skill = selectControl("Familie ESCO", ["all", ...skillCatalog.map(d => d.code)], "all", value => value === "all" ? "Toate familiile" : skillName(value));
    const threshold = rangeControl("Prag", 2, 22, 7, .5, value => `${ro.format(value)}%`);
    const edgesCount = rangeControl("Legături", 12, 60, 38, 1, value => integer.format(value));
    const curvature = rangeControl("Curbură", 20, 100, 70, 5, value => `${value}%`);
    ui.controls.append(field.wrap, skill.wrap, threshold.wrap, edgesCount.wrap, curvature.wrap);
    const host = ui.panel("Arc crosswalk", "Grosimea arcului codifică scorul; culoarea identifică familia ESCO.", true);
    function render() {
      const edges = taxBridge.filter(d => (field.input.value === "all" || d.isced_code === field.input.value) && (skill.input.value === "all" || d.skill_code === skill.input.value) && d.score >= +threshold.input.value / 100).sort((a, b) => b.score - a.score).slice(0, +edgesCount.input.value);
      const fields = fieldCatalog.filter(d => field.input.value === "all" ? edges.some(e => e.isced_code === d.code) : d.code === field.input.value);
      const skills = skillCatalog.filter(d => skill.input.value === "all" ? edges.some(e => e.skill_code === d.code) : d.code === skill.input.value);
      const nodes = [...fields.map(d => ({...d, type: "field"})), ...skills.map(d => ({...d, type: "skill"}))];
      const width = 1120, height = 680, x = d3.scalePoint(nodes.map(d => `${d.type}:${d.code}`), [75, width - 75]).padding(.35), baseline = 545;
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      const arcPath = edge => {
        const x1 = x(`field:${edge.isced_code}`), x2 = x(`skill:${edge.skill_code}`), span = Math.abs(x2 - x1), lift = Math.min(430, span * (+curvature.input.value / 100));
        return `M${x1},${baseline}C${x1},${baseline-lift} ${x2},${baseline-lift} ${x2},${baseline}`;
      };
      const paths = svg.append("g").attr("fill", "none").selectAll("path").data(edges).join("path").attr("d", arcPath).attr("stroke", d => skillColor(d.skill_code)).attr("stroke-width", d => 1 + d.score * 25).attr("stroke-opacity", .34)
        .on("pointerenter", function(event, d) { paths.attr("stroke-opacity", e => e === d ? .95 : .035); d3.select(this).raise(); ui.show(event, `<strong>${d.isced_label_ro}</strong><span>${d.skill_label_ro}</span><b>${pct(d.score)}</b>`); }).on("pointermove", (event, d) => ui.show(event, `<strong>${d.isced_code} → ESCO</strong><span>${d.skill_label_ro}</span><b>${pct(d.score)}</b>`)).on("pointerleave", () => { paths.attr("stroke-opacity", .34); ui.hide(); });
      const g = svg.append("g").selectAll("g").data(nodes).join("g").attr("transform", d => `translate(${x(`${d.type}:${d.code}`)},${baseline})`);
      g.append("circle").attr("r", d => d.type === "field" ? 8 : 10).attr("fill", d => d.type === "field" ? "#0f172a" : skillColor(d.code)).attr("stroke", "#fff").attr("stroke-width", 2);
      g.append("text").attr("transform", "rotate(58)").attr("x", 13).attr("y", 3).attr("font-size", 9).attr("font-weight", 700).attr("fill", "#475569").text(d => d.type === "field" ? `${d.code} ${d.label}` : d.label);
      svg.append("text").attr("x", 75).attr("y", 40).attr("font-size", 11).attr("font-weight", 850).attr("fill", "#64748b").text("DOMENII ISCED-F → FAMILII ESCO");
      mount(host, svg.node());
      setMetrics(ui.metrics, [{label: "Legături", value: integer.format(edges.length), note: `≥ ${threshold.input.value}%`}, {label: "Domenii ISCED", value: integer.format(fields.length), note: "câmpuri largi"}, {label: "Familii ESCO", value: integer.format(skills.length), note: "competențe"}, {label: "Scor maxim", value: pct(d3.max(edges, d => d.score) || 0), note: "crosswalk analitic"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function mapFeatures() {
    const all = taxTopojson.feature(taxWorld, taxWorld.objects.features).features;
    const allowed = new Set(taxMatch.map(d => d.iso3).filter(Boolean));
    return all.filter(feature => allowed.has(feature.properties.id));
  }

  function countryValues({field, age, education, sex = "T", match = "VH_H"}) {
    return taxMatch.filter(d => d.iso3 && d.field_code === field && d.age === age && d.isced_level === education && d.sex === sex && d.match_code === match);
  }

  function bivariateMap() {
    const ui = shell({title: "Harta bivariată a potrivirii educație–job", subtitle: "Nouă culori combină rata de potrivire ridicată cu decalajul femei–bărbați, fără a reduce comparația la o singură variabilă.", accent: "cyan"});
    const field = selectControl("Domeniu ISCED", ["TOTAL", ...fieldCatalog.map(d => d.code)], "TOTAL", value => value === "TOTAL" ? "Toate domeniile" : fieldCatalog.find(d => d.code === value)?.label);
    const age = selectControl("Vârstă", ["Y15-34", "Y25-34"], "Y15-34", value => value === "Y15-34" ? "15–34 ani" : "25–34 ani");
    const education = selectControl("Nivel educațional", ["ED3-8", "ED5-8"], "ED3-8", value => value === "ED3-8" ? "ISCED 3–8" : "ISCED 5–8");
    const focus = selectControl("Țară focală", [...new Set(taxMatch.filter(d => d.iso3).map(d => d.geo))].sort(), "RO", value => taxMatch.find(d => d.geo === value)?.geo_label || value);
    ui.controls.append(field.wrap, age.wrap, education.wrap, focus.wrap);
    const mapHost = ui.panel("Hartă bivariată", "Axa albastră: potrivire ridicată; axa portocalie: avantajul femeilor față de bărbați.", true);
    const scatterHost = ui.panel("Planul celor două variabile", "Poziția fiecărei țări explică nuanța de pe hartă.", true);
    const palette = [["#e8e8e8", "#ace4e4", "#5ac8c8"], ["#dfb0d6", "#a5add3", "#5698b9"], ["#be64ac", "#8c62aa", "#3b4994"]];
    function render() {
      const params = {field: field.input.value, age: age.input.value, education: education.input.value};
      const total = countryValues({...params, sex: "T", match: "VH_H"}), women = countryValues({...params, sex: "F", match: "VH_H"}), men = countryValues({...params, sex: "M", match: "VH_H"});
      const byWomen = new Map(women.map(d => [d.iso3, d.value])), byMen = new Map(men.map(d => [d.iso3, d.value]));
      const data = total.map(d => ({...d, match: d.value, gap: (byWomen.get(d.iso3) ?? NaN) - (byMen.get(d.iso3) ?? NaN)})).filter(d => Number.isFinite(d.gap));
      const matchCuts = [d3.quantile(data.map(d => d.match).sort(d3.ascending), 1/3), d3.quantile(data.map(d => d.match).sort(d3.ascending), 2/3)];
      const gapCuts = [d3.quantile(data.map(d => d.gap).sort(d3.ascending), 1/3), d3.quantile(data.map(d => d.gap).sort(d3.ascending), 2/3)];
      const cls = (value, cuts) => value > cuts[1] ? 2 : value > cuts[0] ? 1 : 0;
      const byIso = new Map(data.map(d => [d.iso3, d])), features = mapFeatures();
      const width = 1100, height = 650, projection = d3.geoMercator().center([14, 53]).scale(560).translate([430, 335]);
      const path = d3.geoPath(projection), svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      svg.append("g").selectAll("path").data(features).join("path").attr("d", path).attr("fill", feature => { const d = byIso.get(feature.properties.id); return d ? palette[cls(d.gap, gapCuts)][cls(d.match, matchCuts)] : "#f1f5f9"; }).attr("stroke", feature => byIso.get(feature.properties.id)?.geo === focus.input.value ? "#f97316" : "#fff").attr("stroke-width", feature => byIso.get(feature.properties.id)?.geo === focus.input.value ? 3.5 : 1).style("cursor", "pointer")
        .on("click", (_, feature) => { const d = byIso.get(feature.properties.id); if (d) { focus.input.value = d.geo; render(); } }).on("pointerenter", (event, feature) => { const d = byIso.get(feature.properties.id); if (d) ui.show(event, `<strong>${d.geo_label}</strong><span>Potrivire ridicată: ${ro.format(d.match)}%</span><b>decalaj F–M: ${ro.format(d.gap)} pp</b>`); }).on("pointermove", (event, feature) => { const d = byIso.get(feature.properties.id); if (d) ui.show(event, `<strong>${d.geo_label}</strong><span>${ro.format(d.match)}% potrivire</span><b>${ro.format(d.gap)} pp F–M</b>`); }).on("pointerleave", ui.hide);
      const lg = svg.append("g").attr("transform", "translate(905,365)");
      palette.forEach((row, gy) => row.forEach((color, gx) => lg.append("rect").attr("x", gx * 36).attr("y", (2-gy) * 36).attr("width", 34).attr("height", 34).attr("fill", color)));
      lg.append("text").attr("x", 54).attr("y", 135).attr("text-anchor", "middle").attr("font-size", 10).attr("fill", "#475569").text("Potrivire ridicată →");
      lg.append("text").attr("transform", "rotate(-90)").attr("x", -54).attr("y", -12).attr("text-anchor", "middle").attr("font-size", 10).attr("fill", "#475569").text("Avantaj femei →");
      mount(mapHost, svg.node());
      const scatter = d3.create("svg").attr("viewBox", [0, 0, 1060, 470]), sx = d3.scaleLinear(d3.extent(data, d => d.match)).nice().range([70, 1020]), sy = d3.scaleLinear(d3.extent(data, d => d.gap)).nice().range([420, 25]);
      scatter.append("g").attr("transform", "translate(0,420)").call(d3.axisBottom(sx)); scatter.append("g").attr("transform", "translate(70,0)").call(d3.axisLeft(sy)); scatter.append("line").attr("x1",70).attr("x2",1020).attr("y1",sy(0)).attr("y2",sy(0)).attr("stroke","#94a3b8").attr("stroke-dasharray","4 4");
      scatter.append("g").selectAll("circle").data(data).join("circle").attr("cx", d => sx(d.match)).attr("cy", d => sy(d.gap)).attr("r", d => d.geo === focus.input.value ? 8 : 5).attr("fill", d => d.geo === focus.input.value ? "#f97316" : "#2563eb").attr("fill-opacity", .75).append("title").text(d => `${d.geo_label}: ${ro.format(d.match)}%, ${ro.format(d.gap)} pp`);
      mount(scatterHost, scatter.node());
      const selected = data.find(d => d.geo === focus.input.value), eu = taxMatch.find(d => d.geo === "EU27_2020" && d.field_code === params.field && d.age === params.age && d.isced_level === params.education && d.sex === "T" && d.match_code === "VH_H");
      setMetrics(ui.metrics, [{label: "Țări cartografiate", value: integer.format(data.length), note: "observații complete"}, {label: "Țara focală", value: selected?.geo || focus.input.value, note: selected?.geo_label || "fără date"}, {label: "Potrivire", value: selected ? `${ro.format(selected.match)}%` : "—", note: `UE-27: ${eu ? ro.format(eu.value) : "—"}%`}, {label: "Decalaj F–M", value: selected ? `${ro.format(selected.gap)} pp` : "—", note: "pozitiv = avantaj femei"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  function dorling() {
    const ui = shell({title: "Cartograma Dorling a alinierii competențelor", subtitle: "Țările devin cercuri proporționale și sunt deplasate controlat pentru a face comparabile valorile foarte apropiate geografic.", accent: "orange"});
    const field = selectControl("Domeniu ISCED", ["TOTAL", ...fieldCatalog.map(d => d.code)], "F05", value => value === "TOTAL" ? "Toate domeniile" : fieldCatalog.find(d => d.code === value)?.label); field.input.value = "F05";
    const match = selectControl("Tip potrivire", ["VH_H", "MOD_LOW", "NMATCH"], "VH_H", value => ({VH_H: "Foarte ridicată / ridicată", MOD_LOW: "Moderată / scăzută", NMATCH: "Fără potrivire"}[value]));
    const sex = selectControl("Sex", ["T", "F", "M"], "T", value => ({T: "Total", F: "Femei", M: "Bărbați"}[value]));
    const age = selectControl("Vârstă", ["Y15-34", "Y25-34"], "Y15-34", value => value === "Y15-34" ? "15–34 ani" : "25–34 ani");
    const education = selectControl("Nivel educațional", ["ED3-8", "ED5-8"], "ED3-8", value => value === "ED3-8" ? "ISCED 3–8" : "ISCED 5–8");
    const bubble = rangeControl("Scală bule", 55, 145, 100, 5, value => `${value}%`);
    ui.controls.append(field.wrap, match.wrap, sex.wrap, age.wrap, education.wrap, bubble.wrap);
    const host = ui.panel("Dorling cartogram", "Conturul geografic rămâne ca reper; liniile arată deplasarea fiecărei țări.", true);
    function render() {
      const data = countryValues({field: field.input.value, age: age.input.value, education: education.input.value, sex: sex.input.value, match: match.input.value});
      const byIso = new Map(data.map(d => [d.iso3, d])), features = mapFeatures().filter(feature => byIso.has(feature.properties.id));
      const width = 1100, height = 690, projection = d3.geoMercator().center([14, 53]).scale(590).translate([550, 360]), path = d3.geoPath(projection);
      const radius = d3.scaleSqrt([0, d3.max(data, d => d.value) || 100], [5, 34 * +bubble.input.value / 100]);
      const color = d3.scaleSequential(d3.interpolateTurbo).domain([0, 100]);
      const nodes = features.map(feature => { const [x, y] = path.centroid(feature), value = byIso.get(feature.properties.id); return {...value, x, y, tx: x, ty: y, r: radius(value.value)}; });
      const simulation = d3.forceSimulation(nodes).force("x", d3.forceX(d => d.tx).strength(.3)).force("y", d3.forceY(d => d.ty).strength(.3)).force("collide", d3.forceCollide(d => d.r + 2).iterations(3)).stop();
      for (let i = 0; i < 180; i++) simulation.tick();
      const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);
      svg.append("g").selectAll("path").data(features).join("path").attr("d", path).attr("fill", "#f8fafc").attr("stroke", "#cbd5e1");
      svg.append("g").selectAll("line").data(nodes).join("line").attr("x1", d => d.tx).attr("y1", d => d.ty).attr("x2", d => d.x).attr("y2", d => d.y).attr("stroke", "#94a3b8").attr("stroke-opacity", .55);
      const g = svg.append("g").selectAll("g").data(nodes).join("g").attr("transform", d => `translate(${d.x},${d.y})`).style("cursor", "pointer")
        .on("pointerenter", (event, d) => ui.show(event, `<strong>${d.geo_label}</strong><span>${d.field_label_ro || d.field_label}</span><b>${ro.format(d.value)}%</b>`)).on("pointermove", (event, d) => ui.show(event, `<strong>${d.geo_label}</strong><span>${d.match_label}</span><b>${ro.format(d.value)}%</b>`)).on("pointerleave", ui.hide);
      g.append("circle").attr("r", 0).attr("fill", d => color(d.value)).attr("fill-opacity", .86).attr("stroke", "#fff").attr("stroke-width", 2).transition().duration(750).delay((_, i) => i * 18).attr("r", d => d.r);
      g.append("text").attr("text-anchor", "middle").attr("dy", ".34em").attr("font-size", d => Math.max(7, Math.min(11, d.r / 2.2))).attr("font-weight", 850).attr("fill", d => d.value > 52 ? "#fff" : "#0f172a").text(d => d.geo);
      mount(host, svg.node());
      const eu = taxMatch.find(d => d.geo === "EU27_2020" && d.field_code === field.input.value && d.age === age.input.value && d.isced_level === education.input.value && d.sex === sex.input.value && d.match_code === match.input.value);
      setMetrics(ui.metrics, [{label: "Țări", value: integer.format(nodes.length), note: "bule cartografiate"}, {label: "Valoare mediană", value: `${ro.format(d3.median(nodes, d => d.value) || 0)}%`, note: match.input.options[match.input.selectedIndex].text}, {label: "UE-27", value: eu ? `${ro.format(eu.value)}%` : "—", note: field.input.options[field.input.selectedIndex].text}, {label: "Interval", value: `${ro.format(d3.min(nodes, d => d.value) || 0)}–${ro.format(d3.max(nodes, d => d.value) || 0)}%`, note: "minim–maxim"}]);
    }
    controlsRender(ui, render); render(); return ui.root;
  }

  return {upset, marimekko, ternary, edgeBundle, radviz, hilbert, tableLens, ridgeline, bridgeArc, bivariateMap, dorling};
}
taxonomyLab.upset()

Ce poți face cu acest grafic?

Fiecare coloană reprezintă o combinație distinctă în diferite grupe de bază ESCO. Bara arată câte grupe ISCO conțin mai multe tipuri de competențe, iar punctele unite indică familiile ESCO care depășesc pragul selectat.

Notă: Acest grafic poate fi aplicat în mai multe contexte, atunci când ai mai multe tipuri de categorii într-un set de date și dorești să cauți interactiv care sunt categoriile comune unei variabile. O poți utiliza pentru a identifica puncte comune și a elimina zgomotul dintr-un set de date.

Înapoi la portofoliu

Marimekko ISCO–ESCO →