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 @@

Layer

+
@@ -259,18 +314,28 @@

Layer

-
-
-

Total jobs

-
+
+ +
+
+

Total jobs

+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
@@ -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")