diff --git a/site/index.html b/site/index.html
index b589dcd..77b5ad0 100644
--- a/site/index.html
+++ b/site/index.html
@@ -175,6 +175,60 @@
}
.gradient-legend canvas { border-radius: 2px; }
+.stats-outer {
+ display: flex;
+ gap: 24px;
+ align-items: flex-start;
+ padding: 0 28px 12px;
+}
+.weight-sliders {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ flex-shrink: 0;
+ background: rgba(255,255,255,0.03);
+ border: 1px solid rgba(255,255,255,0.06);
+ border-radius: 6px;
+ padding: 10px 14px;
+}
+.weight-sliders h3 {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--fg2);
+ margin-bottom: 2px;
+}
+.slider-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+}
+.slider-row label {
+ width: 76px;
+ color: var(--fg2);
+ text-align: right;
+ flex-shrink: 0;
+ font-size: 11px;
+}
+.slider-row input[type="range"] {
+ width: 80px;
+ accent-color: #6c8;
+}
+.slider-row .slider-val {
+ width: 16px;
+ color: var(--fg);
+ font-weight: 600;
+ font-size: 12px;
+}
+.slider-hints {
+ font-size: 10px;
+ color: var(--fg2);
+ opacity: 0.6;
+ margin-top: 2px;
+}
+
canvas#canvas {
display: block;
cursor: default;
@@ -250,6 +304,7 @@
-
-
Total jobs
-
—
+
+
+
Weights
+
Pay 10
+
Outlook 10
+
Education 10
+
AI Exposure 10
+
0 = ignore, 10 = max importance
+
+
-
-
-
-
-
-
-
@@ -343,6 +408,35 @@
Total jobs
return greenRedCSS(v / 10, a);
}
+// Composite composite score: high pay + growing outlook + low education + low AI exposure = green
+const oppWeights = { pay: 10, outlook: 10, education: 10, exposure: 10 };
+
+function compositeScore(d) {
+ let sum = 0, totalW = 0;
+ if (d.pay != null && oppWeights.pay > 0) {
+ const payFloor = 50000, payCeil = 250000;
+ const payNorm = d.pay <= payFloor ? 0 : (Math.log(Math.min(payCeil, d.pay)) - Math.log(payFloor)) / (Math.log(payCeil) - Math.log(payFloor));
+ sum += payNorm * oppWeights.pay; totalW += oppWeights.pay;
+ }
+ if (d.outlook != null && oppWeights.outlook > 0) {
+ const olNorm = Math.max(0, Math.min(1, (d.outlook + 12) / 24));
+ sum += olNorm * oppWeights.outlook; totalW += oppWeights.outlook;
+ }
+ if (d.education && oppWeights.education > 0) {
+ const eduIdx = EDU_LEVELS.indexOf(d.education);
+ if (eduIdx >= 0) { sum += (1 - eduIdx / (EDU_LEVELS.length - 1)) * oppWeights.education; totalW += oppWeights.education; }
+ }
+ if (d.exposure != null && oppWeights.exposure > 0) {
+ sum += (1 - d.exposure / 10) * oppWeights.exposure; totalW += oppWeights.exposure;
+ }
+ return totalW > 0 ? sum / totalW : null;
+}
+function compositeColor(d, a) {
+ const s = compositeScore(d);
+ if (s == null) return `rgba(128,128,128,${a})`;
+ return greenRedCSS(1 - s, a);
+}
+
function tileColorCSS(d, alpha) {
if (colorMode === "exposure") return exposureColor(d.exposure, alpha);
if (colorMode === "outlook") return outlookColor(d.outlook, alpha);
@@ -350,6 +444,7 @@
Total jobs
if (colorMode === "education") {
return eduColor(EDU_LEVELS.indexOf(d.education), alpha);
}
+ if (colorMode === "composite") return compositeColor(d, alpha);
return `rgba(128,128,128,${alpha})`;
}
@@ -379,6 +474,11 @@
Total jobs
}[r.education] || "";
return short + (r.jobs ? " \u00b7 " + formatNumber(r.jobs) + " jobs" : "");
}
+ if (colorMode === "composite") {
+ const s = compositeScore(r);
+ return (s != null ? (s * 100).toFixed(0) + "%" : "") +
+ (r.jobs ? " \u00b7 " + formatNumber(r.jobs) + " jobs" : "");
+ }
return "";
}
@@ -541,6 +641,13 @@
Total jobs
const idx = EDU_LEVELS.indexOf(edu);
const color = idx >= 0 ? eduColor(idx, 1) : "var(--fg)";
return `
Education: ${edu || '\u2014'} `;
+ } else if (colorMode === "composite") {
+ const s = compositeScore(d);
+ if (s == null) return "";
+ const color = compositeColor(d, 1);
+ return `
Opportunity: ${(s * 100).toFixed(0)}% ` +
+ `
`;
}
return "";
}
@@ -554,7 +661,8 @@
Total jobs
Jobs (2024) ${formatNumber(d.jobs)}
Outlook ${d.outlook != null ? d.outlook + '%' : '\u2014'} ${d.outlook_desc ? '(' + d.outlook_desc + ')' : ''}
Education ${d.education || '\u2014'} `;
- tt.querySelector(".tt-rationale").textContent = colorMode === "exposure" ? (d.exposure_rationale || "") : "";
+ tt.querySelector(".tt-rationale").textContent = colorMode === "exposure" ? (d.exposure_rationale || "") :
+ colorMode === "composite" ? "Composite of: high pay + growing outlook + low education requirement + low AI exposure" : "";
let tx = mx + 16, ty = my - 16;
if (tx + 340 > window.innerWidth) tx = mx - 356;
if (ty < 10) ty = my + 16;
@@ -629,6 +737,7 @@
Total jobs
if (colorMode === "outlook") updateOutlookStats(totalJobs);
else if (colorMode === "pay") updatePayStats(totalJobs);
else if (colorMode === "education") updateEducationStats(totalJobs);
+ else if (colorMode === "composite") updateOpportunityStats(totalJobs);
else updateExposureStats(totalJobs);
}
@@ -846,6 +955,65 @@
Total jobs
document.getElementById("block8").innerHTML = "";
}
+function updateOpportunityStats(totalJobs) {
+ // Headline: weighted avg composite score
+ let wS = 0, wC = 0;
+ for (const d of data) {
+ const s = compositeScore(d);
+ if (s != null && d.jobs) { wS += s * d.jobs; wC += d.jobs; }
+ }
+ const avg = wC > 0 ? wS / wC : 0;
+ document.getElementById("block2").innerHTML = `
Avg. composite
+
${(avg * 100).toFixed(0)}%
+
job-weighted composite
`;
+
+ document.getElementById("block3").innerHTML = "";
+
+ // Tier breakdown by score range
+ const tierDefs = [
+ { label: "Excellent (75%+)", lo: 0.75, hi: 1.01 },
+ { label: "Good (60\u201375%)", lo: 0.60, hi: 0.75 },
+ { label: "Average (45\u201360%)", lo: 0.45, hi: 0.60 },
+ { label: "Below avg (30\u201345%)", lo: 0.30, hi: 0.45 },
+ { label: "Low (<30%)", lo: 0, hi: 0.30 },
+ ];
+ const tiers = tierDefs.map(t => {
+ let jobs = 0;
+ for (const d of data) {
+ const s = compositeScore(d);
+ if (s != null && d.jobs && s >= t.lo && s < t.hi) jobs += d.jobs;
+ }
+ return { ...t, jobs, color: greenRedCSS(1 - (t.lo + t.hi) / 2, 1) };
+ });
+ document.getElementById("block4").innerHTML = `
Opportunity tiers ${renderTiers(tiers, totalJobs)}
`;
+
+ // Cross: composite by pay band
+ const byPay = weightedAvgByGroups(PAY_BANDS, (d, g) => d.pay != null && d.pay >= g.min && d.pay < g.max, d => compositeScore(d));
+ document.getElementById("block5").innerHTML = `
Opportunity by pay ${renderHbars(byPay.map(g => ({
+ label: g.label, val: (g.avg * 100).toFixed(0) + "%",
+ pct: g.avg * 100, color: greenRedCSS(1 - g.avg, 0.8)
+ })))}
`;
+
+ // Cross: composite by education
+ const byEdu = weightedAvgByGroups(EDU_GROUPS, (d, g) => g.match.includes(d.education), d => compositeScore(d));
+ document.getElementById("block6").innerHTML = `
Opportunity by education ${renderHbars(byEdu.map(g => ({
+ label: g.label, val: (g.avg * 100).toFixed(0) + "%",
+ pct: g.avg * 100, color: greenRedCSS(1 - g.avg, 0.8)
+ })))}
`;
+
+ // Impact: jobs in excellent tier
+ let excellentJobs = 0;
+ for (const d of data) {
+ const s = compositeScore(d);
+ if (s != null && s >= 0.75 && d.jobs) excellentJobs += d.jobs;
+ }
+ document.getElementById("block7").innerHTML = `
Excellent tier
+
${(excellentJobs / 1e6).toFixed(1)}M
+
jobs scoring 75%+
`;
+
+ document.getElementById("block8").innerHTML = "";
+}
+
// ── Events ─────────────────────────────────────────────────────────────
canvas.addEventListener("mousemove", (e) => {
@@ -865,6 +1033,7 @@
Total jobs
outlook: { low: "Declining", high: "Growing" },
pay: { low: "$25K", high: "$250K" },
education: { low: "No degree", high: "Doctoral" },
+ composite: { low: "Low", high: "High" },
};
function drawGradientLegend() {
@@ -891,10 +1060,22 @@
Total jobs
updateStats();
drawGradientLegend();
draw();
+ document.getElementById("weightSliders").style.display = colorMode === "composite" ? "flex" : "none";
// Re-layout since stats height may have changed
requestAnimationFrame(resize);
});
+document.getElementById("weightSliders").addEventListener("input", (e) => {
+ const input = e.target.closest("input[data-weight]");
+ if (!input) return;
+ const key = input.dataset.weight;
+ const val = parseInt(input.value);
+ oppWeights[key] = val;
+ input.nextElementSibling.textContent = val;
+ updateStats();
+ draw();
+});
+
// ── Load ───────────────────────────────────────────────────────────────
fetch("data.json")