ESCO, ISCO, UpSet plot, competențe, D3.js, Observable
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
Sursă: ESCO. Ponderile sunt cele publicate de ESCO; pragul este o alegere exploratorie a utilizatorului.
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"]);
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;
}
return {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.
Proiecte conexe
::::::