@@ -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
164165const 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+
166337function 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
0 commit comments