Public
Edited
Jun 4
Insert cell
Insert cell
import {d3} from "@observablehq/stdlib"
Insert cell
data = FileAttachment("FINAL_jobs_2020_2024_comparison_table_true.csv").csv()
Insert cell
years = [2020, 2021, 2022, 2023, 2024]
Insert cell
viewof selectedYear = Inputs.range([years[0], years[years.length - 1]], {
step: 1,
label: "Select Year",
value: 2024
})
Insert cell
previousYear = (() => {
const index = years.indexOf(selectedYear);
return index > 0 ? years[index - 1] : null;
})()
Insert cell
selectedYearLines = previousYear
? top15TracedData
.filter(d => d.year === selectedYear || d.year === previousYear)
: []
Insert cell
visibleYears = years.filter(y => y <= selectedYear)
Insert cell
viewof selectedJob = Inputs.select(
[...new Set(data.map(d => d.OCC_TITLE))].sort(),
{label: "Select Occupation"}
)
Insert cell
viewof sortMetric = Inputs.radio(["Employment", "Wage"], {
label: "Sort Top 15 Industries By",
value: "Employment"
})
Insert cell
longData = data.flatMap(d => {
return years.map(y => {
const emp = +d[`EMP_${y}`];
const wage = +d[`WAGE_${y}`];
return (isNaN(emp) || isNaN(wage)) ? null : {
job: d.OCC_TITLE,
industry: d.NAICS_TITLE,
year: y,
employment: emp,
wage: wage
};
}).filter(Boolean);
});
Insert cell
aggregatedData = d3.rollups(
longData,
v => ({
employment: d3.median(v, d => d.employment),
wage: d3.median(v, d => d.wage)
}),
d => `${d.job}|${d.industry}|${d.year}`
).map(([key, value]) => {
const [job, industry, year] = key.split("|");
return {
job,
industry,
year: +year,
employment: value.employment,
wage: value.wage
};
});
Insert cell
filteredData = aggregatedData
.filter(d => d.job === selectedJob && d.year === selectedYear)
.sort((a, b) =>
sortMetric === "Employment"
? b.employment - a.employment
: b.wage - a.wage
)
.slice(0, 15)
Insert cell
top15Data = sectorFilteredData
.filter(d => d.year === selectedYear)
.sort((a, b) => b.employment - a.employment)
.slice(0, 15)
Insert cell
top15TracedData = sectorFilteredData.filter(d =>
top15Data.map(td => td.industry.trim()).includes(d.industry.trim())
)
Insert cell
industries = [...new Set(longData.map(d => d.industry))].sort()
Insert cell
industryColor = Object.fromEntries(
industries.map((industry, i) => [industry, d3.schemeTableau10[i % 10]])
)
Insert cell
industrySectors = ({
Healthcare: ["Health", "Hospitals", "Medical"],
Education: ["Schools", "Education", "Universities"],
Tech: ["Software", "Computer", "Data", "Information"],
Manufacturing: ["Manufacturing", "Machinery", "Fabricated"],
Hospitality: ["Accommodation", "Food", "Entertainment"],
Government: ["Public", "Government"],
Finance: ["Finance", "Insurance", "Real Estate"]
})
Insert cell
viewof selectedSector = Inputs.select(
["All", ...Object.keys(industrySectors)],
{label: "Industry Sector Filter", value: "All"}
)
Insert cell
sectorFilteredData = selectedSector === "All"
? filteredData
: filteredData.filter(d =>
industrySectors[selectedSector].some(keyword =>
d.industry.toLowerCase().includes(keyword.toLowerCase())
)
)
Insert cell
extentByJob = (() => {
const filtered = aggregatedData.filter(d => d.job === selectedJob);
return {
employmentExtent: d3.extent(filtered, d => d.employment),
wageExtent: d3.extent(filtered, d => d.wage)
};
})()
Insert cell
employmentExtent = extentByJob.employmentExtent
Insert cell
wageExtent = extentByJob.wageExtent
Insert cell
medianEmployment = sectorFilteredData.length ? d3.median(sectorFilteredData, d => d.employment) : null
Insert cell
medianWage = sectorFilteredData.length ? d3.median(sectorFilteredData, d => d.wage) : null
Insert cell
usMedianWage = 60000
Insert cell
top15IndustryNames = aggregatedData
.filter(d => d.job === selectedJob && d.year === selectedYear)
.sort((a, b) => b.employment - a.employment)
.slice(0, 15)
.map(d => d.industry)
Insert cell
tracedIndustryData = aggregatedData.filter(d =>
top15IndustryNames.includes(d.industry) &&
d.job === selectedJob &&
d.year >= 2020 &&
d.year <= selectedYear
)
Insert cell
viewof scatter = {
const groupedTraced = d3.groups(tracedIndustryData, d => d.industry);

const svg = Plot.plot({
width: 800,
height: 500,
marginLeft: 70,
marginBottom: 60,
x: {
label: "Employment",
domain: employmentExtent,
grid: true
},
y: {
label: "Annual Wage ($)",
domain: wageExtent,
grid: true
},
color: null,
marks: [
// 🟡 Smaller grey dots for previous years with year in tooltip
Plot.dot(
tracedIndustryData.filter(d => d.year < selectedYear && d.employment > 0 && d.wage > 0),
{
x: "employment",
y: "wage",
r: 2, // half the size
fill: "#ccc",
opacity: 0.8,
tip: true,
title: d =>
`${d.industry} (${d.year})\nYear: ${d.year}\nEmployment: ${d3.format(",")(d.employment)}\nWage: $${d3.format(",")(d.wage)}`
}
),

// 🔗 Grey trend lines connecting valid year-to-year data
...groupedTraced.flatMap(([industry, values]) => {
const sorted = values.sort((a, b) => a.year - b.year);
const segments = [];
let segment = [];

for (const point of sorted) {
const isValid = point.wage > 0 && point.employment > 0;
if (isValid) {
segment.push(point);
} else if (segment.length > 1) {
segments.push([...segment]);
segment = [];
} else {
segment = [];
}
}

if (segment.length > 1) {
segments.push(segment);
}

return segments.map(segment =>
Plot.line(segment, {
x: "employment",
y: "wage",
stroke: "#ccc",
strokeWidth: 1
})
);
}),

// 🎯 Colored dots for selected year (top 15)
Plot.dot(top15Data, {
x: "employment",
y: "wage",
r: 6,
fill: d => industryColor[d.industry],
tip: true,
title: d =>
`${d.industry}\nYear: ${d.year}\nEmployment: ${d3.format(",")(d.employment)}\nWage: $${d3.format(",")(d.wage)}`
}),

// 📉 U.S. Median Wage line
Plot.ruleY([usMedianWage], {
stroke: "red",
strokeWidth: 1.5,
strokeDasharray: "4"
}),

// 🏷️ Label for U.S. Median Wage
Plot.text([{
x: employmentExtent[0] * 1.05,
y: usMedianWage + 2000,
text: "US Median Wage ($60k)"
}], {
x: "x",
y: "y",
text: "text",
fontSize: 10,
fill: "red",
textAnchor: "start"
}),

// 🗓️ Large year label (top right)
Plot.text([{
x: employmentExtent[1] * 0.98,
y: wageExtent[1] * 0.98,
text: selectedYear
}], {
x: "x",
y: "y",
text: "text",
fontSize: 40,
fill: "#eee",
textAnchor: "end"
})
]
});

return svg;
}
Insert cell
viewof industryLegend = html`<div style="display:flex; flex-wrap:wrap; gap:0.75rem; font-size:11px; line-height:1.2;">
${top15Data
.map(d => d.industry)
.filter((v, i, a) => a.indexOf(v) === i)
.map(industry => html`
<div style="display:flex; align-items:center; gap:4px;">
<div style="width:10px; height:10px; background:${industryColor[industry]}; border-radius:2px;"></div>
<span>${industry.length > 22 ? industry.slice(0, 20) + '…' : industry}</span>
</div>
`)}
</div>`
Insert cell
viewof highestPayingIndustryNote = html`
<div style="font-size:14px; line-height:1.4;">
${
sortMetric === "Employment"
? `In <b>${selectedYear}</b>, among the top 15 industries with the highest <b>employment</b> for <i>${selectedJob}</i>,
<span style="color:${industryColor[top15Data.sort((a, b) => b.wage - a.wage)[0].industry]}; font-weight:bold;">
${top15Data.sort((a, b) => b.wage - a.wage)[0].industry}</span> offered the highest average wage —
<b>$${top15Data.sort((a, b) => b.wage - a.wage)[0].wage.toFixed(0)}</b>.`
: `In <b>${selectedYear}</b>, among the top 15 <b>highest-paying</b> industries for <i>${selectedJob}</i>,
<span style="color:${industryColor[top15Data.sort((a, b) => b.employment - a.employment)[0].industry]}; font-weight:bold;">
${top15Data.sort((a, b) => b.employment - a.employment)[0].industry}</span> employed the most people —
<b>${d3.format(",")(top15Data.sort((a, b) => b.employment - a.employment)[0].employment)}</b>.`
}
</div>
`
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more