Skip to content

Commit d614c6f

Browse files
committed
Replace orientation bars with student mosaic
1 parent 72e7e6b commit d614c6f

5 files changed

Lines changed: 353 additions & 18 deletions

File tree

milestone2/index.html

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@ <h1>Teen Health Profile</h1>
1919
<section id="section-demographics">
2020
<p class="section-kicker">1. Orientation</p>
2121
<h2>Who Are We Looking At?</h2>
22-
<p class="section-desc">Sample composition of the 20,103 surveyed students. These bars show raw survey rows; health estimates below use the CDC survey weight.</p>
23-
<div id="demo-charts" class="chart-row"></div>
22+
<p class="section-desc">The YRBS starts as a survey cohort before it becomes a health profile. This opening view shows raw survey composition; later health estimates use the CDC survey weight.</p>
23+
<div class="orientation-layout">
24+
<aside id="survey-passport" class="survey-passport"></aside>
25+
<div class="mosaic-panel">
26+
<div class="mosaic-toolbar">
27+
<div class="controls mosaic-controls">
28+
<label>Color by:</label>
29+
<div id="mosaic-controls" class="segmented-control" aria-label="Mosaic color dimension"></div>
30+
</div>
31+
<div id="mosaic-legend" class="mosaic-legend"></div>
32+
</div>
33+
<div id="student-mosaic"></div>
34+
</div>
35+
</div>
2436
</section>
2537

2638
<!-- SECTION 2: SHALLOW NATIONAL PROFILE -->

milestone2/main.js

Lines changed: 176 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ d3.csv("data/yrbs2023_readable.csv").then(raw => {
8282
d.age = d.Q1_label;
8383
});
8484

85-
// Filter out rows with missing key demographics
85+
drawDemographics(raw);
86+
87+
// Filter out rows with missing key demographics for the analytic views.
8688
const data = raw.filter(d => d.sex && d.grade);
8789

88-
drawDemographics(data);
8990
drawDotPlot(data, "sex");
9091
drawGradeShift(data);
9192
initRiskProfiler(data);
@@ -163,12 +164,181 @@ function applyDemoFilters(data, excluding = null) {
163164
// Shared reusable tooltip
164165
const demoTooltip = d3.select("body").append("div").attr("class", "demo-tooltip");
165166

167+
const MOSAIC_UNIT = 50;
168+
let mosaicColorField = "grade";
169+
170+
const MOSAIC_DIMENSIONS = [
171+
{
172+
field: "grade",
173+
label: "Grade",
174+
order: ["9th grade", "10th grade", "11th grade", "12th grade", "Ungraded or other grade"],
175+
},
176+
{
177+
field: "sex",
178+
label: "Sex",
179+
order: ["Male", "Female"],
180+
},
181+
{
182+
field: "race",
183+
label: "Race / Ethnicity",
184+
order: ["White", "Multiple - Hispanic", "Black or African American",
185+
"Multiple - Non-Hispanic", "Am Indian/Alaska Native",
186+
"Hispanic/Latino", "Asian", "Native Hawaiian/Other PI"],
187+
},
188+
{
189+
field: "age",
190+
label: "Age",
191+
order: ["12 years old or younger", "13 years old", "14 years old",
192+
"15 years old", "16 years old", "17 years old", "18 years old or older"],
193+
},
194+
];
195+
196+
function mosaicDimension(field) {
197+
return MOSAIC_DIMENSIONS.find(d => d.field === field) ?? MOSAIC_DIMENSIONS[0];
198+
}
199+
200+
function orderedMosaicCategories(data, dimension) {
201+
const counts = d3.rollups(
202+
data.filter(d => d[dimension.field]),
203+
v => v.length,
204+
d => d[dimension.field]
205+
);
206+
const countMap = new Map(counts);
207+
const orderedKeys = [
208+
...dimension.order.filter(key => countMap.has(key)),
209+
...counts.map(([key]) => key).filter(key => !dimension.order.includes(key)).sort(d3.ascending),
210+
];
211+
212+
return orderedKeys.map((key, i) => ({
213+
key,
214+
count: countMap.get(key),
215+
color: colorFor(key, i),
216+
}));
217+
}
218+
219+
function renderSurveyPassport(data) {
220+
const rowsWithWeight = data.filter(d => weightOf(d)).length;
221+
const gradeRows = data.filter(d => d.grade && d.grade !== "Ungraded or other grade").length;
222+
223+
d3.select("#survey-passport").html(`
224+
<div class="passport-label">Dataset Passport</div>
225+
<div class="passport-title">CDC YRBS 2023</div>
226+
<div class="passport-metrics">
227+
<div class="passport-metric">
228+
<span class="passport-value">${data.length.toLocaleString()}</span>
229+
<span class="passport-caption">student rows</span>
230+
</div>
231+
<div class="passport-metric">
232+
<span class="passport-value">${gradeRows.toLocaleString()}</span>
233+
<span class="passport-caption">rows in grades 9-12</span>
234+
</div>
235+
<div class="passport-metric">
236+
<span class="passport-value">${rowsWithWeight.toLocaleString()}</span>
237+
<span class="passport-caption">rows with survey weight</span>
238+
</div>
239+
<div class="passport-metric">
240+
<span class="passport-value">250</span>
241+
<span class="passport-caption">variables after conversion</span>
242+
</div>
243+
</div>
244+
<p class="passport-note">One mosaic dot represents about ${MOSAIC_UNIT} student rows. Health estimates below use weighted percentages.</p>
245+
`);
246+
}
247+
248+
function renderMosaicControls(data) {
249+
const controls = d3.select("#mosaic-controls");
250+
controls.selectAll("*").remove();
251+
252+
MOSAIC_DIMENSIONS.forEach(dimension => {
253+
controls.append("button")
254+
.attr("type", "button")
255+
.attr("class", `segment-button${dimension.field === mosaicColorField ? " active" : ""}`)
256+
.text(dimension.label)
257+
.on("click", () => {
258+
mosaicColorField = dimension.field;
259+
renderMosaicControls(data);
260+
drawStudentMosaic(data);
261+
});
262+
});
263+
}
264+
265+
function drawStudentMosaic(data) {
266+
d3.select("#student-mosaic").selectAll("*").remove();
267+
d3.select("#mosaic-legend").selectAll("*").remove();
268+
269+
const dimension = mosaicDimension(mosaicColorField);
270+
const categories = orderedMosaicCategories(data, dimension);
271+
const totalForDimension = d3.sum(categories, d => d.count);
272+
const dots = categories.flatMap(category => {
273+
const count = Math.max(1, Math.round(category.count / MOSAIC_UNIT));
274+
return d3.range(count).map(() => ({ ...category, totalForDimension }));
275+
});
276+
277+
const columns = 34;
278+
const cell = 16;
279+
const radius = 5.5;
280+
const margin = { top: 20, right: 20, bottom: 22, left: 20 };
281+
const W = columns * cell + margin.left + margin.right;
282+
const H = Math.ceil(dots.length / columns) * cell + margin.top + margin.bottom;
283+
284+
const svg = d3.select("#student-mosaic")
285+
.append("svg")
286+
.attr("viewBox", `0 0 ${W} ${H}`)
287+
.attr("width", "100%")
288+
.style("max-width", "760px")
289+
.style("display", "block");
290+
291+
svg.append("text")
292+
.attr("x", margin.left)
293+
.attr("y", 12)
294+
.style("font-size", "11px")
295+
.style("font-weight", "700")
296+
.style("fill", "#5c6472")
297+
.text(`${dots.length.toLocaleString()} dots, about ${MOSAIC_UNIT} rows per dot`);
298+
299+
svg.append("g")
300+
.attr("transform", `translate(${margin.left},${margin.top})`)
301+
.selectAll("circle")
302+
.data(dots)
303+
.join("circle")
304+
.attr("cx", (_d, i) => (i % columns) * cell + cell / 2)
305+
.attr("cy", (_d, i) => Math.floor(i / columns) * cell + cell / 2)
306+
.attr("r", radius)
307+
.attr("fill", d => d.color)
308+
.attr("fill-opacity", 0.88)
309+
.attr("stroke", "#fff")
310+
.attr("stroke-width", 1)
311+
.on("mouseover", (_event, d) => {
312+
const pct = d.totalForDimension ? (d.count / d.totalForDimension * 100).toFixed(1) : "0.0";
313+
demoTooltip
314+
.style("opacity", 1)
315+
.html(`<strong>${d.key}</strong><br>${d.count.toLocaleString()} rows<br>${pct}% of known ${dimension.label.toLowerCase()}`);
316+
})
317+
.on("mousemove", event => {
318+
demoTooltip
319+
.style("left", (event.pageX + 14) + "px")
320+
.style("top", (event.pageY - 42) + "px");
321+
})
322+
.on("mouseout", () => demoTooltip.style("opacity", 0));
323+
324+
const legend = d3.select("#mosaic-legend");
325+
categories.forEach(category => {
326+
const pct = totalForDimension ? (category.count / totalForDimension * 100).toFixed(1) : "0.0";
327+
const item = legend.append("div").attr("class", "legend-item");
328+
item.append("span")
329+
.attr("class", "legend-swatch")
330+
.style("background", category.color);
331+
item.append("span")
332+
.attr("class", "legend-label")
333+
.text(`${category.key} ${pct}%`);
334+
});
335+
}
336+
166337
function drawDemographics(allData) {
167338
_allDemoData = allData;
168-
// Set grid columns proportional to naturalW so all charts render at equal height
169-
d3.select("#demo-charts")
170-
.style("grid-template-columns", DEMO_SPECS.map(s => `${s.naturalW}fr`).join(" "));
171-
renderDemographics(allData);
339+
renderSurveyPassport(allData);
340+
renderMosaicControls(allData);
341+
drawStudentMosaic(allData);
172342
}
173343

174344
// Keep a reference so filters can trigger re-renders

milestone2/style.css

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,141 @@ section h2 {
133133
cursor: pointer;
134134
}
135135

136+
/* ── Orientation passport + mosaic ── */
137+
.orientation-layout {
138+
display: grid;
139+
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
140+
gap: 24px;
141+
align-items: start;
142+
}
143+
144+
.survey-passport {
145+
border: 1px solid #dfe5ef;
146+
border-radius: 8px;
147+
background: #fff;
148+
padding: 18px;
149+
}
150+
151+
.passport-label {
152+
margin-bottom: 5px;
153+
color: #4e79a7;
154+
font-size: 0.72rem;
155+
font-weight: 800;
156+
letter-spacing: 0.08em;
157+
text-transform: uppercase;
158+
}
159+
160+
.passport-title {
161+
margin-bottom: 16px;
162+
color: #20283a;
163+
font-size: 1.2rem;
164+
font-weight: 800;
165+
}
166+
167+
.passport-metrics {
168+
display: grid;
169+
grid-template-columns: 1fr;
170+
gap: 10px;
171+
}
172+
173+
.passport-metric {
174+
display: flex;
175+
flex-direction: column;
176+
gap: 2px;
177+
padding: 10px 0;
178+
border-top: 1px solid #edf0f5;
179+
}
180+
181+
.passport-value {
182+
color: #20283a;
183+
font-size: 1.35rem;
184+
font-weight: 800;
185+
}
186+
187+
.passport-caption,
188+
.passport-note {
189+
color: #5c6472;
190+
font-size: 0.8rem;
191+
line-height: 1.35;
192+
}
193+
194+
.passport-note {
195+
margin: 14px 0 0;
196+
padding-top: 12px;
197+
border-top: 1px solid #edf0f5;
198+
}
199+
200+
.mosaic-panel {
201+
min-width: 0;
202+
}
203+
204+
.mosaic-toolbar {
205+
display: flex;
206+
align-items: flex-start;
207+
justify-content: space-between;
208+
gap: 16px;
209+
margin-bottom: 12px;
210+
}
211+
212+
.mosaic-controls {
213+
margin-bottom: 0;
214+
}
215+
216+
.segmented-control {
217+
display: inline-flex;
218+
flex-wrap: wrap;
219+
gap: 6px;
220+
}
221+
222+
.segment-button {
223+
min-height: 32px;
224+
padding: 6px 10px;
225+
border: 1px solid #d6dbe5;
226+
border-radius: 8px;
227+
background: #fff;
228+
color: #303849;
229+
font-size: 0.82rem;
230+
cursor: pointer;
231+
}
232+
233+
.segment-button.active {
234+
border-color: #20283a;
235+
background: #20283a;
236+
color: #fff;
237+
font-weight: 700;
238+
}
239+
240+
.mosaic-legend {
241+
display: flex;
242+
flex-wrap: wrap;
243+
justify-content: flex-end;
244+
gap: 6px 10px;
245+
max-width: 470px;
246+
}
247+
248+
.legend-item {
249+
display: inline-flex;
250+
align-items: center;
251+
gap: 5px;
252+
color: #4d5666;
253+
font-size: 0.74rem;
254+
white-space: nowrap;
255+
}
256+
257+
.legend-swatch {
258+
width: 9px;
259+
height: 9px;
260+
border-radius: 50%;
261+
flex: 0 0 auto;
262+
}
263+
264+
#student-mosaic {
265+
border: 1px solid #e3e7ef;
266+
border-radius: 8px;
267+
background: #fff;
268+
padding: 10px 12px;
269+
}
270+
136271
/* ── Demographics grid ── */
137272
.chart-row {
138273
display: grid;
@@ -478,6 +613,18 @@ svg text {
478613
grid-template-columns: 1fr;
479614
}
480615

616+
.orientation-layout {
617+
grid-template-columns: 1fr;
618+
}
619+
620+
.mosaic-toolbar {
621+
flex-direction: column;
622+
}
623+
624+
.mosaic-legend {
625+
justify-content: flex-start;
626+
}
627+
481628
.risk-layout {
482629
grid-template-columns: 1fr;
483630
}

0 commit comments

Comments
 (0)