Redesign chart visualizations to match Civilytics white-paper style
Deploy to git-pages / deploy (push) Successful in 18s
Deploy to git-pages / deploy (push) Successful in 18s
Rebuilds all 6 charts around a validated categorical race palette, row-based sex encoding, and a consistent observed-vs-modeled mark convention (diamond vs. filled bar/density) instead of ad hoc per-chart color schemes. Adds a shared student-group filter (defaults to all 8 groups) that scopes every chart's data from one place in ChartPanel. Drops the D3 dependency entirely in favor of plain SVG, removing the imperative-DOM bug class behind this app's repeated "fix the fix" commits. Replaces the fake symmetric-normal posterior approximation with a skewed, median-preserving fit to the API's interval bounds, clearly labeled as an approximation. Fixes two broken SVG fill attributes, a decorative model dropdown that never affected its chart, dead code, an orphaned component, and a broken CSS token reference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Generated
-461
@@ -8,9 +8,6 @@
|
||||
"name": "crdc-arrests-demo",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||
"eslint": "^8.57.1",
|
||||
@@ -838,15 +835,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -869,407 +857,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1295,15 +882,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1682,18 +1260,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -1750,15 +1316,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -2467,12 +2024,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
|
||||
@@ -2530,18 +2081,6 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
|
||||
+1
-4
@@ -25,8 +25,5 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"vite": "^8.2.1"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0"
|
||||
}
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
|
||||
|
||||
/**
|
||||
* Chart 1: Observed arrests (raw) by CRDC wave — line chart.
|
||||
* Each point labeled with the rate per 1,000 students.
|
||||
* Source: /estimates?leaid=XXXXX&year=... across all three waves (total).
|
||||
* Chart 1: Observed arrests by CRDC wave (line), overlaid with the
|
||||
* three-year model's predicted total (point-range) per wave — the
|
||||
* observed-vs-modeled comparison this app is built around.
|
||||
*/
|
||||
|
||||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
||||
const DODGE = 14 // px offset so the modeled point-range doesn't sit on top of the observed point
|
||||
|
||||
export default function ArrestsOverTime({ data, districtName }) {
|
||||
// Compute max for scaling the chart area
|
||||
const maxArrests = Math.max(...data.map(d => d.arrests), 1)
|
||||
const maxRate = Math.max(
|
||||
...data.map(d => (d.enroll > 0 ? (d.arrests / (d.enroll / 1000)) : 0)),
|
||||
0.1
|
||||
const maxArrests = Math.max(
|
||||
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
|
||||
1
|
||||
)
|
||||
|
||||
// Chart dimensions (SVG-based, no D3 dependency)
|
||||
const width = 360
|
||||
const height = 280
|
||||
const margin = { top: 50, right: 90, bottom: 60, left: 65 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
// Scales (manual linear interpolation)
|
||||
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
|
||||
const yScale = (val) => innerHeight - (val / maxArrests) * innerHeight
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Observed arrests over time by CRDC wave
|
||||
Arrests over time — observed vs. modeled
|
||||
</h3>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines + labels (arrests) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxArrests
|
||||
if (val === 0 && frac > 0) return null
|
||||
@@ -52,13 +49,11 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={15} y={margin.top + innerHeight / 2} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 15 ${margin.top + innerHeight / 2})`}>
|
||||
Total arrests (raw count)
|
||||
Total arrests (count)
|
||||
</text>
|
||||
|
||||
{/* X-axis labels */}
|
||||
{data.map((d, i) => (
|
||||
<text key={d.year} x={margin.left + xScale(i)} y={height - margin.bottom + 15}
|
||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">
|
||||
@@ -66,20 +61,34 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
|
||||
|
||||
{/* Line connecting points */}
|
||||
{/* Observed line */}
|
||||
{data.length > 1 && (
|
||||
<polyline
|
||||
points={data.map((d, i) => `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
|
||||
fill="none" stroke="var(--cv-navy-600)" strokeWidth={2.5}
|
||||
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2.5}
|
||||
strokeLinejoin="round" strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points with rate-per-1k labels */}
|
||||
{/* Modeled point-range per wave */}
|
||||
{data.map((d, i) => {
|
||||
if (d.modeledMedian == null) return null
|
||||
const cx = margin.left + xScale(i) + DODGE
|
||||
const cyMedian = margin.top + yScale(d.modeledMedian)
|
||||
const cyLower = margin.top + yScale(d.modeledLower ?? d.modeledMedian)
|
||||
const cyUpper = margin.top + yScale(d.modeledUpper ?? d.modeledMedian)
|
||||
return (
|
||||
<g key={`modeled-${d.year}`}>
|
||||
<line x1={cx} y1={cyLower} x2={cx} y2={cyUpper} stroke={MODELED_AGGREGATE_COLOR} strokeWidth={2} />
|
||||
<circle cx={cx} cy={cyMedian} r={4.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1.25} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Observed points + rate-per-1k labels */}
|
||||
{data.map((d, i) => {
|
||||
const cx = margin.left + xScale(i)
|
||||
const cy = margin.top + yScale(d.arrests)
|
||||
@@ -87,10 +96,7 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
|
||||
return (
|
||||
<g key={d.year}>
|
||||
{/* Point circle */}
|
||||
<circle cx={cx} cy={cy} r={5} fill="var(--cv-navy-600)" stroke="#fff" strokeWidth={1.5} />
|
||||
|
||||
{/* Rate label above point */}
|
||||
<circle cx={cx} cy={cy} r={5} fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5} />
|
||||
<text x={cx} y={cy - 14} textAnchor="middle" fontSize="0.7rem" fontWeight={600}
|
||||
fill="var(--cv-ink)">{ratePerK}</text>
|
||||
<text x={cx} y={cy - 5} textAnchor="middle" fontSize="0.6rem"
|
||||
@@ -99,15 +105,19 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* District name watermark */}
|
||||
<text x={margin.left + innerWidth - 4} y={margin.top + 12} textAnchor="end"
|
||||
fontSize="0.6rem" fill="var(--cv-ink-4)">{districtName}</text>
|
||||
</svg>
|
||||
|
||||
{/* Footer note */}
|
||||
<ChartLegend items={[
|
||||
{ shape: 'line', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||
{ shape: 'diamond', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
||||
]} />
|
||||
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Raw observed arrest counts (left axis) with rate per 1,000 students labeled above each point.
|
||||
Data from CRDC waves 2015–16 through 2021–22.
|
||||
Rate per 1,000 students labeled above each observed point. The modeled total sums each
|
||||
selected group's median/interval independently, which approximates but is not exactly the
|
||||
median of the combined total. Data from CRDC waves 2015–16 through 2021–22.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import { raceColor, OBSERVED_MARK_COLOR, REFERENCE_GRAY } from '../utils/colors.js'
|
||||
|
||||
/**
|
||||
* Chart 3: Arrest rate for the student group with the most arrests compared to the national average.
|
||||
* Side-by-side bars or point-range comparison.
|
||||
* Chart 3: The district's highest-rate student group vs. the national rate
|
||||
* for that same group. District bar = modeled median (race-colored) with a
|
||||
* 90% interval whisker; dark diamond = observed rate on the same bar.
|
||||
* National bar is a neutral gray reference, not a race/model color.
|
||||
*/
|
||||
|
||||
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
|
||||
@@ -8,89 +13,85 @@ export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup
|
||||
const height = 300
|
||||
const margin = { top: 45, right: 30, bottom: 80, left: 70 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const plotHeight = height - margin.top - margin.bottom
|
||||
|
||||
// Compute max rate for scaling (district + national)
|
||||
const districtRate = topGroup.observedRate || 0
|
||||
const maxVal = Math.max(districtRate, nationalRate, 1) * 1.3
|
||||
const districtObserved = topGroup.observedRate || 0
|
||||
const districtModeled = topGroup.modeledMedian ?? districtObserved
|
||||
const districtLower = topGroup.rateLower ?? districtModeled
|
||||
const districtUpper = topGroup.rateUpper ?? districtModeled
|
||||
const maxVal = Math.max(districtUpper, districtObserved, nationalRate, 1) * 1.3
|
||||
|
||||
const yFor = (val) => margin.top + plotHeight * (1 - val / maxVal)
|
||||
const color = raceColor(topGroup.race)
|
||||
|
||||
const districtBarX = margin.left + innerWidth * 0.1
|
||||
const districtBarW = innerWidth * 0.35
|
||||
const nationalBarX = margin.left + innerWidth * 0.55
|
||||
const nationalBarW = innerWidth * 0.35
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
District vs. national rate — highest group
|
||||
</h3>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={plotHeight} fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxVal
|
||||
if (val > maxVal) return null
|
||||
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxVal) * (height - margin.top - margin.bottom))
|
||||
const y = yFor(val)
|
||||
return (
|
||||
<g key={`y-${frac}`}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
|
||||
stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={margin.left - 8} y={y + 4} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
|
||||
<text x={12} y={margin.top + plotHeight / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)"
|
||||
transform={`rotate(-90 12 ${margin.top + plotHeight / 2})`}>
|
||||
Rate per 1,000 students
|
||||
</text>
|
||||
|
||||
{/* Two side-by-side bars: District vs National */}
|
||||
<g>
|
||||
{/* District bar */}
|
||||
<rect x={margin.left + innerWidth * 0.1} y={margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal)}
|
||||
width={innerWidth * 0.35} height={(height - margin.top - margin.bottom) * (districtRate / maxVal)}
|
||||
fill="var(--cv-navy-600)" rx={4} />
|
||||
<text x={margin.left + innerWidth * 0.1 + innerWidth * 0.35 / 2} y={margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal) - 4}
|
||||
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{districtRate.toFixed(2)}</text>
|
||||
{/* District bar — modeled median, race-colored, with 90% interval whisker */}
|
||||
<rect x={districtBarX} y={yFor(districtModeled)} width={districtBarW} height={plotHeight * (districtModeled / maxVal)}
|
||||
fill={color} rx={4} />
|
||||
<line x1={districtBarX + districtBarW / 2} y1={yFor(districtLower)} x2={districtBarX + districtBarW / 2} y2={yFor(districtUpper)}
|
||||
stroke="var(--cv-ink)" strokeWidth={1.5} />
|
||||
{/* Observed diamond, overlaid on the district bar */}
|
||||
<rect x={districtBarX + districtBarW / 2 - 5} y={yFor(districtObserved) - 5} width={10} height={10}
|
||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.25}
|
||||
transform={`rotate(45 ${districtBarX + districtBarW / 2} ${yFor(districtObserved)})`} />
|
||||
|
||||
{/* National bar */}
|
||||
<rect x={margin.left + innerWidth * 0.55} y={margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal)}
|
||||
width={innerWidth * 0.35} height={(height - margin.top - margin.bottom) * (nationalRate / maxVal)}
|
||||
fill="var(--cv-accent)" rx={4} />
|
||||
<text x={margin.left + innerWidth * 0.55 + innerWidth * 0.35 / 2} y={margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal) - 4}
|
||||
{/* National bar — neutral reference */}
|
||||
<rect x={nationalBarX} y={yFor(nationalRate)} width={nationalBarW} height={plotHeight * (nationalRate / maxVal)}
|
||||
fill={REFERENCE_GRAY} rx={4} />
|
||||
<text x={nationalBarX + nationalBarW / 2} y={yFor(nationalRate) - 4}
|
||||
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{nationalRate.toFixed(2)}</text>
|
||||
|
||||
{/* X-axis labels */}
|
||||
<text x={margin.left + innerWidth * 0.1 + innerWidth * 0.35 / 2} y={height - margin.bottom + 15}
|
||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">District</text>
|
||||
<text x={margin.left + innerWidth * 0.55 + innerWidth * 0.35 / 2} y={height - margin.bottom + 15}
|
||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">National</text>
|
||||
<text x={districtBarX + districtBarW / 2} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">District</text>
|
||||
<text x={nationalBarX + nationalBarW / 2} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">National</text>
|
||||
|
||||
{/* Group label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
|
||||
fontSize="0.65rem" fontWeight={600} fill="var(--cv-navy-700)}">
|
||||
{topGroup.label || 'Top group'} — observed rate per 1,000
|
||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-navy-700)">
|
||||
{topGroup.label || 'Top group'}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Ratio annotation */}
|
||||
<foreignObject x={margin.left + innerWidth * 0.42 - 30} y={Math.min(margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal), margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal)) - 25} width="60" height="20">
|
||||
<div style={{ fontSize: '0.7rem', textAlign: 'center', color: 'var(--cv-ink-3)' }}>
|
||||
{districtRate > 0 ? `${(districtRate / nationalRate).toFixed(1)}×` : ''}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<ChartLegend items={[
|
||||
{ shape: 'swatch', color, label: 'District (modeled + 90% interval)' },
|
||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'District (observed)' },
|
||||
{ shape: 'swatch', color: REFERENCE_GRAY, label: 'National reference' },
|
||||
]} />
|
||||
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
{topGroup.label || 'Highest-rate group'} in this district has a rate of{' '}
|
||||
<strong>{districtRate.toFixed(2)}</strong> per 1,000 vs. the national average of{' '}
|
||||
{topGroup.label || 'Highest-rate group'} in this district has an observed rate of{' '}
|
||||
<strong>{districtObserved.toFixed(2)}</strong> per 1,000 vs. the national average of{' '}
|
||||
<strong>{nationalRate.toFixed(2)}</strong>. This is{' '}
|
||||
{districtRate > nationalRate * 1.5 ? 'substantially higher' : districtRate > nationalRate
|
||||
? 'slightly higher' : districtRate < nationalRate * 0.75
|
||||
? 'much lower'
|
||||
: 'comparable'} to the national rate.
|
||||
{districtObserved > nationalRate * 1.5 ? 'substantially higher than' : districtObserved > nationalRate
|
||||
? 'slightly higher than' : districtObserved < nationalRate * 0.75
|
||||
? 'much lower than'
|
||||
: 'comparable to'} the national rate.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,161 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { groupKey } from '../utils/groupKey.js'
|
||||
import { groupLabel } from '../utils/colors.js'
|
||||
import { fitSkewedInterval, probabilityExceeds } from '../utils/distributionApprox.js'
|
||||
import ApproxNote from '../components/ApproxNote.jsx'
|
||||
|
||||
/**
|
||||
* Chart 6: Probability the district exceeds the national rate for each student group.
|
||||
* Uses posterior draws (approximated via interval bounds + median) to compute
|
||||
* P(district_rate > national_rate) per race×sex group.
|
||||
* Chart 6: Probability the district exceeds the national rate for each
|
||||
* student group, under a chosen model. The model dropdown now actually
|
||||
* drives the computation (it was previously decorative — computed always
|
||||
* read from a single ChartPanel-precomputed default model).
|
||||
*/
|
||||
|
||||
export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
|
||||
const MODEL_OPTIONS = [
|
||||
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
|
||||
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
|
||||
]
|
||||
|
||||
export default function ExceedanceProbability({ quadData, nationalRates }) {
|
||||
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
|
||||
|
||||
// Compute exceedance probability for each group using normal approximation:
|
||||
// P(district_rate > national) ≈ Φ((modeled_median - national) / modeled_sd)
|
||||
// where sd is derived from the interval width (upper - lower) / (2 * z_{0.95})
|
||||
const computed = rateByGroup.map(d => {
|
||||
const natKey = `${d.race}_${d.sex}`
|
||||
const rows = (quadData && quadData[selectedModel]) || []
|
||||
const computed = rows
|
||||
.map((row) => {
|
||||
const natKey = groupKey(row.race, row.sex)
|
||||
const nationalRate = nationalRates[natKey] || 0
|
||||
|
||||
// Use modeled median and interval from the estimate data if available
|
||||
// (rate_median is per student; multiply by 1000 for per-1k)
|
||||
const modeledMedianPerK = ((d.modeledRateMedian || d.observedRate * (d.enroll || 1) / 1000))
|
||||
// Actually: rate_median is already a probability (per-student). Convert to per-1k.
|
||||
const modeledRatePerK = ((d.rate_median || 0) * 1000) || d.observedRate
|
||||
|
||||
// Derive SD from interval width if available, else use observed as proxy
|
||||
const lowerPerK = ((d.rate_lower || 0) * 1000) || (modeledRatePerK * 0.7)
|
||||
const upperPerK = ((d.rate_upper || 0) * 1000) || (modeledRatePerK * 1.3)
|
||||
// sd ≈ (upper - lower) / (2 * 1.645) for a 90% interval under normality
|
||||
const modeledSd = Math.max((upperPerK - lowerPerK) / 3.29, 0.05)
|
||||
|
||||
// P(district > national) using normal CDF approximation
|
||||
const z = (modeledRatePerK - nationalRate) / modeledSd
|
||||
const probExceeds = normalCDF(z)
|
||||
|
||||
return {
|
||||
...d,
|
||||
natKey,
|
||||
nationalRate,
|
||||
modeledRate: modeledRatePerK,
|
||||
modeledSd,
|
||||
probExceeds: Math.max(0.01, Math.min(0.99, probExceeds)), // clamp to avoid extremes
|
||||
}
|
||||
}).sort((a, b) => b.probExceeds - a.probExceeds)
|
||||
const rateMedian = (row.rate_median || 0) * 1000
|
||||
const rateLower = (row.rate_lower || 0) * 1000
|
||||
const rateUpper = (row.rate_upper || 0) * 1000
|
||||
const fit = fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper })
|
||||
const probExceeds = Math.max(0.01, Math.min(0.99, probabilityExceeds(fit, nationalRate)))
|
||||
return { race: row.race, sex: row.sex, label: groupLabel(row.race, row.sex), nationalRate, modeledRate: rateMedian, probExceeds }
|
||||
})
|
||||
.sort((a, b) => b.probExceeds - a.probExceeds)
|
||||
|
||||
const width = 400
|
||||
const height = 320
|
||||
const margin = { top: 50, right: 100, bottom: 90, left: 70 }
|
||||
const margin = { top: 50, right: 100, bottom: 60, left: 130 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const plotHeight = height - margin.top - margin.bottom
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Probability district exceeds national rate — by student group
|
||||
</h3>
|
||||
<ApproxNote />
|
||||
|
||||
{/* Model selector (advanced toggle) */}
|
||||
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)}
|
||||
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
|
||||
{[
|
||||
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
|
||||
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
|
||||
].map(opt => (
|
||||
style={{ margin: 'var(--space-1) 0', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
|
||||
{MODEL_OPTIONS.map((opt) => (
|
||||
<option key={opt.val} value={opt.val}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{computed.length === 0 ? (
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available for the selected groups.</p>
|
||||
) : (
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom} fill="var(--cv-paper-2)" rx={4} />
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={plotHeight} fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines (probability scale 0–1) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const y = margin.top + ((height - margin.top - margin.bottom) - frac * (height - margin.top - margin.bottom))
|
||||
const y = margin.top + plotHeight - frac * plotHeight
|
||||
return (
|
||||
<g key={`y-${frac}`}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={0.5} />
|
||||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-3)}">{frac.toFixed(2)}</text>
|
||||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-3)">{frac.toFixed(2)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={14} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 14 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
|
||||
<text x={14} y={margin.top + plotHeight / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)"
|
||||
transform={`rotate(-90 14 ${margin.top + plotHeight / 2})`}>
|
||||
P(district rate > national)
|
||||
</text>
|
||||
|
||||
{/* Bars */}
|
||||
{/* 50% reference line — coin-flip threshold, not tied to any one group's national rate */}
|
||||
<line x1={margin.left + innerWidth * 0.5} y1={margin.top} x2={margin.left + innerWidth * 0.5} y2={margin.top + plotHeight}
|
||||
stroke="var(--cv-ink-4)" strokeWidth={1} strokeDasharray="3,2" />
|
||||
|
||||
{computed.map((d, i) => {
|
||||
const barHeight = Math.min(22, (innerWidth * 0.65) / computed.length)
|
||||
const barHeight = Math.min(22, (plotHeight * 0.9) / computed.length)
|
||||
const y = margin.top + i * (barHeight + 4)
|
||||
const probWidth = d.probExceeds * innerWidth
|
||||
|
||||
// Color by threshold: green if <50%, amber if ~50-75%, red if >75%
|
||||
let barColor
|
||||
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)' // "not likely" → green
|
||||
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)' // "possible" → amber
|
||||
else barColor = 'var(--cv-danger)' // "likely" → red
|
||||
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)'
|
||||
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)'
|
||||
else barColor = 'var(--cv-danger)'
|
||||
|
||||
return (
|
||||
<g key={`${d.race}-${d.sex}`}>
|
||||
{/* Bar */}
|
||||
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
|
||||
|
||||
{/* Probability label at end of bar */}
|
||||
{d.probExceeds > 0.05 && (
|
||||
<text x={margin.left + probWidth + 4} y={y + barHeight / 2 + 3} fontSize="0.65rem" fill="var(--cv-ink)">{Math.round(d.probExceeds * 100)}%</text>
|
||||
<text x={margin.left + probWidth + 4} y={y + barHeight / 2 + 3} fontSize="0.65rem" fill="var(--cv-ink)">
|
||||
{Math.round(d.probExceeds * 100)}%
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Group label */}
|
||||
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{d.label}</text>
|
||||
|
||||
{/* National rate reference line at 50% */}
|
||||
{i === Math.floor(computed.length / 2) && (
|
||||
<line x1={margin.left + innerWidth * 0.5} y1={y + barHeight / 2 - 8}
|
||||
x2={margin.left + innerWidth * 0.5} y2={height - margin.bottom - 4}
|
||||
stroke="var(--cv-accent)" strokeWidth={1} strokeDasharray="3,2" />
|
||||
)}
|
||||
|
||||
{/* National rate annotation */}
|
||||
<text x={margin.left + innerWidth * 0.5 + 4} y={y + barHeight / 2 - 8} fontSize="0.6rem" fill="var(--cv-accent)" style={{ display: i === Math.floor(computed.length / 2) ? 'block' : 'none' }}>
|
||||
national = {d.nationalRate.toFixed(2)}/1k
|
||||
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">
|
||||
{d.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 8} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">
|
||||
Probability (higher = district more likely to exceed national rate)
|
||||
</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height - 45})`}>
|
||||
<rect x={0} y={0} width={12} height={12} fill="var(--moss-600)" rx={2} />
|
||||
<text x={18} y={10} fontSize="0.6rem" fill="var(--cv-ink)">< 50% (not likely)</text>
|
||||
<rect x={140} y={0} width={12} height={12} fill="var(--brass-600)" rx={2} />
|
||||
<text x={158} y={10} fontSize="0.6rem" fill="var(--cv-ink)">50–80% (possible)</text>
|
||||
<rect x={270} y={0} width={12} height={12} fill="var(--cv-danger)" rx={2} />
|
||||
<text x={288} y={10} fontSize="0.6rem" fill="var(--cv-ink)">> 80% (likely)</text>
|
||||
<g transform={`translate(${margin.left}, ${margin.top - 22})`}>
|
||||
<rect x={0} y={0} width={10} height={10} fill="var(--moss-600)" rx={2} />
|
||||
<text x={15} y={9} fontSize="0.58rem" fill="var(--cv-ink)">< 50%</text>
|
||||
<rect x={65} y={0} width={10} height={10} fill="var(--brass-600)" rx={2} />
|
||||
<text x={80} y={9} fontSize="0.58rem" fill="var(--cv-ink)">50–80%</text>
|
||||
<rect x={145} y={0} width={10} height={10} fill="var(--cv-danger)" rx={2} />
|
||||
<text x={160} y={9} fontSize="0.58rem" fill="var(--cv-ink)">> 80%</text>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Summary note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Based on the {selectedModel === 'unified_m2_mod' ? 'one-year + covariate model (recommended)' : 'three-year + covariate model'}, which balances precision with current-year data. Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
|
||||
Based on the {selectedModel === 'unified_m2_mod' ? 'one-year + covariate model (recommended)' : 'three-year + covariate model'}.
|
||||
Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard normal CDF approximation (Abramowitz & Stegun)
|
||||
function normalCDF(z) {
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z))
|
||||
const d = 0.3989423 * Math.exp(-z * z / 2)
|
||||
let prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))))
|
||||
return z > 0 ? 1 - prob : prob
|
||||
}
|
||||
|
||||
// Inject a CSS fix for the broken fill attribute in ExceedanceProbability and DistrictVsNational
|
||||
const style = document.createElement('style')
|
||||
|
||||
@@ -1,364 +1,122 @@
|
||||
import { useMemo, useRef, useEffect } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
|
||||
|
||||
/**
|
||||
* Chart 4: Predicted draws by year vs. observed — with raw posterior draws from API.
|
||||
* Shows model's predicted distribution alongside the observed value,
|
||||
* faceted by four quadrants (1yr baseline / 1yr covariate / 3yr baseline / 3yr covariate).
|
||||
* Chart 4: Modeled vs. observed arrests, faceted by the four model-quadrant
|
||||
* specifications (one-year/three-year × baseline/+referral-rate covariate).
|
||||
* Plain SVG (no D3) — the quadrant label already conveys 1yr/3yr, so all
|
||||
* four panels share one ink-diamond/navy-point-range convention rather than
|
||||
* doubling up with a redundant per-quadrant hue.
|
||||
*/
|
||||
|
||||
const QUADRANT_LABELS = [
|
||||
{ key: 'unified_m1_mod', label: 'One-year, no covariate' },
|
||||
{ key: 'unified_m2_mod', label: 'One-year + referral rate' },
|
||||
{ key: 'unified_m3_mod', label: 'Three-year, no covariate' },
|
||||
{ key: 'unified_m4_mod', label: 'Three-year + referral rate' },
|
||||
]
|
||||
|
||||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
||||
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
||||
|
||||
const COLORS = {
|
||||
observed: '#c92d0e', // Red for observed
|
||||
model1yr: '#0791b6', // Teal for one-year models
|
||||
model3yr: '#000a9b', // Navy for three-year models
|
||||
}
|
||||
|
||||
export default function ModelDrawsComparison({ data, waves, districtName }) {
|
||||
// Derive per-wave observed arrest totals from the time series
|
||||
const waveData = useMemo(() => {
|
||||
return ['15-16', '17-18', '21-22'].map(year => {
|
||||
export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) {
|
||||
const waveData = waves.map((year) => {
|
||||
const rows = (data.waves && data.waves[year]) || []
|
||||
return {
|
||||
year, label: WAVE_LABELS[year],
|
||||
arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0),
|
||||
enroll: rows.reduce((sum, r) => sum + (r.stu_enroll || 0), 0),
|
||||
}
|
||||
})
|
||||
}, [data.waves])
|
||||
|
||||
// Handle both cases: when quadData exists as object or is null/undefined
|
||||
const quadModels = (data && data.quadData) ? data.quadData : {}
|
||||
// modelDraws may be null if API unavailable, handle gracefully
|
||||
const modelDraws = (data && data.modelDraws) ? data.modelDraws : null
|
||||
|
||||
// Compute max arrests for Y-axis scaling across all quadrants and years
|
||||
const allArrestValues = []
|
||||
waveData.forEach(d => allArrestValues.push(d.arrests))
|
||||
|
||||
// Also include predicted values
|
||||
Object.entries(quadModels).forEach(([modelId, rows]) => {
|
||||
['15-16', '17-18', '21-22'].forEach(year => {
|
||||
const yearRow = (rows || []).find(r => r.year === year)
|
||||
if (yearRow) {
|
||||
allArrestValues.push(yearRow.count_median || 0)
|
||||
allArrestValues.push(yearRow.count_upper || 0)
|
||||
}
|
||||
})
|
||||
return { year, arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0) }
|
||||
})
|
||||
|
||||
// Add draw values if available (when modelDraws is populated)
|
||||
const drawValues = modelDraws || {}
|
||||
Object.values(drawValues).forEach(groups => {
|
||||
Object.values(groups).forEach(draws => {
|
||||
if (draws && draws.length > 0) {
|
||||
allArrestValues.push(...draws)
|
||||
}
|
||||
})
|
||||
})
|
||||
const quadModels = (data && data.quadData) || {}
|
||||
|
||||
const maxArrests = Math.min(Math.max(...allArrestValues, 1), 50) // Cap at 50 for readability
|
||||
const allValues = waveData.map((d) => d.arrests)
|
||||
Object.values(quadModels).forEach((rows) => {
|
||||
;(rows || []).forEach((r) => {
|
||||
allValues.push(r.count_median || 0, r.count_upper || 0)
|
||||
})
|
||||
})
|
||||
const maxArrests = Math.min(Math.max(...allValues, 1), 50) // cap for readability
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Model predictions vs. observed — by wave & model type
|
||||
Model predictions vs. observed — by wave & model specification
|
||||
</h3>
|
||||
|
||||
{/* Four small multiples (one per quadrant) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
|
||||
{QUADRANT_LABELS.map((q) => (
|
||||
<QuadrantChart
|
||||
key={q.key}
|
||||
q={q}
|
||||
waveData={waveData}
|
||||
quadModels={quadModels}
|
||||
modelDraws={modelDraws ? modelDraws[q.key] : null}
|
||||
maxArrests={maxArrests}
|
||||
/>
|
||||
{MODEL_QUADRANTS.map((q) => (
|
||||
<QuadrantPanel key={q.model} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', marginTop: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.7rem' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: COLORS.observed }} />
|
||||
Observed arrests
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.7rem' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: COLORS.model1yr }} />
|
||||
One-year model (90% interval)
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.7rem' }}>
|
||||
<span style={{ width: 8, height: 3, background: COLORS.model3yr }} />
|
||||
Three-year model (90% HPD)
|
||||
</span>
|
||||
</div>
|
||||
<ChartLegend items={[
|
||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||
{ shape: 'line', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
||||
]} />
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
<strong>Red circles</strong> = observed arrests. Points with error bars show Bayesian model median and 90% interval.
|
||||
Three-year models span multiple waves; one-year models only show most recent 2021–22.
|
||||
Three-year models span all three CRDC waves; one-year models show 2021–22 only.{' '}
|
||||
{districtName ? `District: ${districtName}.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single quadrant chart: predicted vs observed for one model specification */
|
||||
function QuadrantChart({ q, waveData, quadModels, modelDraws, maxArrests }) {
|
||||
const svgRef = useRef(null)
|
||||
function QuadrantPanel({ q, waveData, quadModels, maxArrests }) {
|
||||
const isThreeYear = q.model.includes('m3') || q.model.includes('m4')
|
||||
const showYears = isThreeYear ? ALL_WAVES : ['21-22']
|
||||
|
||||
// For three-year models show all 3 waves; for one-year only the most recent
|
||||
const isThreeYear = q.key.includes('m3') || q.key.includes('m4')
|
||||
const showYears = isThreeYear ? ['15-16', '17-18', '21-22'] : ['21-22']
|
||||
|
||||
useEffect(() => {
|
||||
const container = svgRef.current?.parentElement
|
||||
const width = Math.min(320, container?.clientWidth || 320)
|
||||
const height = 220
|
||||
const margin = { top: 35, right: 15, bottom: 45, left: 40 }
|
||||
const width = 260
|
||||
const height = 190
|
||||
const margin = { top: 26, right: 12, bottom: 26, left: 34 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
// Clear previous
|
||||
d3.select(svgRef.current).selectAll('*').remove()
|
||||
|
||||
const svg = d3.select(svgRef.current)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
// Scales
|
||||
const xScale = d3.scaleLinear()
|
||||
.domain([0, showYears.length - 1])
|
||||
.range([margin.left, margin.left + innerWidth])
|
||||
|
||||
const yScale = d3.scaleLinear()
|
||||
.domain([0, maxArrests])
|
||||
.range([margin.top + innerHeight, margin.top])
|
||||
|
||||
// Gridlines
|
||||
svg.append('g')
|
||||
.attr('class', 'grid')
|
||||
.selectAll('line')
|
||||
.data(yScale.ticks(5))
|
||||
.join('line')
|
||||
.attr('x1', margin.left)
|
||||
.attr('x2', margin.left + innerWidth)
|
||||
.attr('y1', d => yScale(d))
|
||||
.attr('y2', d => yScale(d))
|
||||
.attr('stroke', '#f0f0f0')
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
// X-axis (wave labels)
|
||||
svg.append('g')
|
||||
.attr('transform', `translate(0,${margin.top + innerHeight})`)
|
||||
.call(d3.axisBottom(xScale).tickFormat(i => WAVE_LABELS[showYears[i]] || '').ticks(showYears.length))
|
||||
.call(g => g.select('.domain').attr('stroke', '#ddd'))
|
||||
.call(g => g.selectAll('.tick line').remove())
|
||||
.call(g => g.selectAll('.tick text')
|
||||
.attr('font-size', '0.6rem')
|
||||
.attr('fill', '#666'))
|
||||
|
||||
// Y-axis
|
||||
svg.append('g')
|
||||
.attr('transform', `translate(${margin.left},0)`)
|
||||
.call(d3.axisLeft(yScale).ticks(5))
|
||||
.call(g => g.select('.domain').remove())
|
||||
.call(g => g.selectAll('.tick line').attr('stroke', '#eee'))
|
||||
.call(g => g.selectAll('.tick text').attr('font-size', '0.6rem').attr('fill', '#666'))
|
||||
|
||||
// Y-axis label
|
||||
svg.append('text')
|
||||
.attr('x', 10)
|
||||
.attr('y', margin.top + innerHeight / 2)
|
||||
.attr('transform', `rotate(-90 10 ${margin.top + innerHeight / 2})`)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.55rem')
|
||||
.attr('fill', '#999')
|
||||
.text('Arrests')
|
||||
|
||||
// Colors
|
||||
const baseColor = isThreeYear ? COLORS.model3yr : COLORS.model1yr
|
||||
|
||||
// Process and render each year
|
||||
showYears.forEach((year, i) => {
|
||||
const x = xScale(i)
|
||||
|
||||
// Observed data
|
||||
const obsData = waveData.find(d => d.year === year) || { arrests: 0 }
|
||||
const obsY = yScale(Math.min(obsData.arrests, maxArrests))
|
||||
|
||||
// Model predictions (from summary stats)
|
||||
const yearRow = (quadModels[q.key] || []).find(r => r.year === year)
|
||||
const predMedian = yearRow?.count_median || 0
|
||||
const predLower = yearRow?.count_lower || 0
|
||||
const predUpper = yearRow?.count_upper || 0
|
||||
|
||||
// Raw draws (from API) - sum across all demographic groups for this year
|
||||
const rawDraws = modelDraws ? Object.values(modelDraws).flat() : []
|
||||
|
||||
if (rawDraws && rawDraws.length > 0) {
|
||||
// Use actual posterior draws for visualization
|
||||
|
||||
// Create histogram bins from raw draws
|
||||
const binGenerator = d3.bin()
|
||||
.domain([0, maxArrests])
|
||||
.thresholds(20)
|
||||
|
||||
const bins = binGenerator(rawDraws.filter(d => d <= maxArrests * 1.5))
|
||||
const maxBin = d3.max(bins, b => b.length) || 1
|
||||
|
||||
// Normalize heights - shift histogram up above x-axis
|
||||
const histHeight = innerHeight * 0.25 // 25% of chart height for distribution
|
||||
const histY = margin.top + innerHeight
|
||||
|
||||
// Draw density shape
|
||||
if (bins.length > 0) {
|
||||
const area = d3.area()
|
||||
.x(b => xScale(i) + ((b.x0 + b.x1) / 2 - i) * innerWidth / (showYears.length - 1 || 1) * 0.7)
|
||||
.y0(histY)
|
||||
.y1(b => histY - (b.length / maxBin) * histHeight)
|
||||
.curve(d3.curveBasis)
|
||||
|
||||
svg.append('path')
|
||||
.datum(bins.filter(b => b.length > 0))
|
||||
.attr('d', area)
|
||||
.attr('fill', baseColor)
|
||||
.attr('opacity', 0.5)
|
||||
}
|
||||
|
||||
// Add median line from draws
|
||||
const drawMedian = d3.median(rawDraws)
|
||||
svg.append('line')
|
||||
.attr('x1', x - 4)
|
||||
.attr('x2', x + 4)
|
||||
.attr('y1', yScale(drawMedian))
|
||||
.attr('y2', yScale(drawMedian))
|
||||
.attr('stroke', baseColor)
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
// Add 90% interval from draws
|
||||
const drawLower = d3.quantile(rawDraws.sort(d3.ascending), 0.05)
|
||||
const drawUpper = d3.quantile(rawDraws.sort(d3.ascending), 0.95)
|
||||
|
||||
svg.append('line')
|
||||
.attr('x1', x)
|
||||
.attr('x2', x)
|
||||
.attr('y1', yScale(drawUpper))
|
||||
.attr('y2', yScale(drawLower))
|
||||
.attr('stroke', baseColor)
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
svg.append('line')
|
||||
.attr('x1', x - 3)
|
||||
.attr('x2', x + 3)
|
||||
.attr('y1', yScale(drawUpper))
|
||||
.attr('y2', yScale(drawUpper))
|
||||
.attr('stroke', baseColor)
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
svg.append('line')
|
||||
.attr('x1', x - 3)
|
||||
.attr('x2', x + 3)
|
||||
.attr('y1', yScale(drawLower))
|
||||
.attr('y2', yScale(drawLower))
|
||||
.attr('stroke', baseColor)
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
// Add observed on top (diamond)
|
||||
svg.append('path')
|
||||
.attr('d', `M${x},${obsY - 5} L${x + 4},${obsY} L${x},${obsY + 5} L${x - 4},${obsY} Z`)
|
||||
.attr('fill', COLORS.observed)
|
||||
|
||||
// Label observed value
|
||||
if (obsData.arrests > 0) {
|
||||
svg.append('text')
|
||||
.attr('x', x)
|
||||
.attr('y', obsY - 8)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.55rem')
|
||||
.attr('fill', COLORS.observed)
|
||||
.text(Math.round(obsData.arrests))
|
||||
}
|
||||
|
||||
} else if (yearRow) {
|
||||
// Fall back to interval-based rendering
|
||||
const medianY = yScale(Math.min(predMedian, maxArrests))
|
||||
|
||||
// Error bar
|
||||
svg.append('line')
|
||||
.attr('x1', x)
|
||||
.attr('x2', x)
|
||||
.attr('y1', yScale(Math.min(predUpper, maxArrests)))
|
||||
.attr('y2', yScale(Math.min(predLower, maxArrests)))
|
||||
.attr('stroke', baseColor)
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
// Median point
|
||||
svg.append('circle')
|
||||
.attr('cx', x)
|
||||
.attr('cy', medianY)
|
||||
.attr('r', 3)
|
||||
.attr('fill', baseColor)
|
||||
|
||||
// Observed (diamond)
|
||||
svg.append('path')
|
||||
.attr('d', `M${x},${obsY - 5} L${x + 4},${obsY} L${x},${obsY + 5} L${x - 4},${obsY} Z`)
|
||||
.attr('fill', COLORS.observed)
|
||||
|
||||
// Label
|
||||
if (obsData.arrests > 0) {
|
||||
svg.append('text')
|
||||
.attr('x', x)
|
||||
.attr('y', obsY - 8)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.55rem')
|
||||
.attr('fill', COLORS.observed)
|
||||
.text(Math.round(obsData.arrests))
|
||||
}
|
||||
} else {
|
||||
// No model data - just show observed
|
||||
svg.append('path')
|
||||
.attr('d', `M${x},${obsY - 5} L${x + 4},${obsY} L${x},${obsY + 5} L${x - 4},${obsY} Z`)
|
||||
.attr('fill', COLORS.observed)
|
||||
|
||||
if (obsData.arrests > 0) {
|
||||
svg.append('text')
|
||||
.attr('x', x)
|
||||
.attr('y', obsY - 8)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.55rem')
|
||||
.attr('fill', COLORS.observed)
|
||||
.text(Math.round(obsData.arrests))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Quadrant label at top
|
||||
svg.append('text')
|
||||
.attr('x', margin.left + innerWidth / 2)
|
||||
.attr('y', 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.65rem')
|
||||
.attr('fill', '#444')
|
||||
.attr('font-weight', 500)
|
||||
.text(q.label)
|
||||
|
||||
}, [waveData, quadModels, modelDraws, q, maxArrests])
|
||||
const xScale = (i) => margin.left + (showYears.length > 1 ? (i / (showYears.length - 1)) * innerWidth : innerWidth / 2)
|
||||
const yScale = (val) => margin.top + innerHeight - (Math.min(val, maxArrests) / maxArrests) * innerHeight
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: 'auto' }} />
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
<text x={width / 2} y={14} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-ink-2)">
|
||||
{q.label}
|
||||
</text>
|
||||
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxArrests
|
||||
const y = yScale(val)
|
||||
return (
|
||||
<g key={frac}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.55rem" fill="var(--cv-ink-3)">{Math.round(val)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{showYears.map((year, i) => {
|
||||
const x = xScale(i)
|
||||
const obs = waveData.find((d) => d.year === year)?.arrests ?? 0
|
||||
const yearRow = (quadModels[q.model] || []).find((r) => r.year === year)
|
||||
const obsY = yScale(obs)
|
||||
|
||||
return (
|
||||
<g key={year}>
|
||||
<text x={x} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
||||
{WAVE_LABELS[year]}
|
||||
</text>
|
||||
|
||||
{yearRow && (
|
||||
<>
|
||||
<line
|
||||
x1={x} y1={yScale(yearRow.count_upper ?? yearRow.count_median ?? 0)}
|
||||
x2={x} y2={yScale(yearRow.count_lower ?? yearRow.count_median ?? 0)}
|
||||
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5}
|
||||
/>
|
||||
<circle cx={x} cy={yScale(yearRow.count_median ?? 0)} r={3.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<rect
|
||||
x={x - 4} y={obsY - 4} width={8} height={8}
|
||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||||
transform={`rotate(45 ${x} ${obsY})`}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Chart 5: Observed rate per group against model distribution.
|
||||
* Shows observed arrest rate and overlays the posterior draw distributions from all four models
|
||||
* as ridgeline-style density bars (proxy using interval bounds + median).
|
||||
* Mirrors wp_fig_group_density() / wp_fig_hpd_ridges() from paper_figures.R.
|
||||
*/
|
||||
|
||||
const MODEL_LABELS = {
|
||||
unified_m1_mod: 'One-year, baseline',
|
||||
unified_m2_mod: 'One-year + covariate',
|
||||
unified_m3_mod: 'Three-year, baseline',
|
||||
unified_m4_mod: 'Three-year + covariate',
|
||||
}
|
||||
|
||||
export default function ObservedRateDensity({ quadData }) {
|
||||
const [expandedGroup, setExpandedGroup] = useState(0) // first group by default
|
||||
|
||||
if (!quadData || Object.keys(quadData).length === 0) {
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Observed rate vs. model distribution — by student group
|
||||
</h3>
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract unique groups from the default model (unified_m2_mod)
|
||||
const defaultModel = quadData['unified_m2_mod'] || []
|
||||
const groups = [...new Map(defaultModel.map(r => [`${r.race}-${r.sex}`, { race: r.race, sex: r.sex, label: getLabel(r.race, r.sex) }])).values()]
|
||||
|
||||
// Get data for the expanded group across all 4 models
|
||||
const currentGroup = groups[expandedGroup] || groups[0]
|
||||
const modelResults = Object.entries(quadData).map(([modelId, rows]) => {
|
||||
const row = rows.find(r => r.race === currentGroup?.race && r.sex === currentGroup?.sex)
|
||||
if (!row) return null
|
||||
|
||||
// Convert rate median/interval to per-1k scale for visualization
|
||||
const enroll = row.stu_enroll || 0
|
||||
const observedRate = (row.observed_arrests || 0) / ((enroll || 1) / 1000)
|
||||
const modeledRateMedian = (row.rate_median || 0) * 1000 // rate is per student, convert to per 1k
|
||||
const modeledLower = (row.rate_lower || 0) * 1000
|
||||
const modeledUpper = (row.rate_upper || 0) * 1000
|
||||
|
||||
return { modelId, observedRate, modeledRateMedian, modeledLower, modeledUpper }
|
||||
}).filter(Boolean)
|
||||
|
||||
// Max value for scaling the ridgeline chart
|
||||
const maxRate = Math.max(...modelResults.map(r => r.modeledUpper), ...modelResults.map(r => r.observedRate), 0.5)
|
||||
|
||||
const width = 380
|
||||
const height = 280
|
||||
const margin = { top: 40, right: 30, bottom: 80, left: 60 }
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Observed rate vs. model distribution — {currentGroup?.label || 'Select a group'}
|
||||
</h3>
|
||||
|
||||
{/* Group selector */}
|
||||
<select value={expandedGroup} onChange={(e) => setExpandedGroup(Number(e.target.value))}
|
||||
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
|
||||
{groups.map((g, i) => (
|
||||
<option key={`${g.race}-${g.sex}`} value={i}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Ridgeline-style visualization (proxy using interval bars) */}
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height + modelResults.length * 25}`} style={{ maxWidth: '100%' }}>
|
||||
{modelResults.map((r, i) => {
|
||||
const rowY = margin.top + i * (height / modelResults.length + 8)
|
||||
const barHeight = height / modelResults.length - 6
|
||||
// Pre-compute coordinates outside JSX to avoid embedding statements in render output
|
||||
const obsWidth = (r.observedRate / maxRate) * (width - margin.left - margin.right)
|
||||
const lowerX = margin.left + (r.modeledLower / maxRate) * (width - margin.left - margin.right)
|
||||
const upperWidth = ((r.modeledUpper - r.modeledLower) / maxRate) * (width - margin.left - margin.right)
|
||||
const medX = margin.left + (r.modeledRateMedian / maxRate) * (width - margin.left - margin.right)
|
||||
|
||||
return (
|
||||
<g key={r.modelId}>
|
||||
{/* Background band */}
|
||||
<rect x={margin.left} y={rowY} width={width - margin.left - margin.right} height={barHeight} rx={3} fill="var(--cv-paper-2)" />
|
||||
|
||||
{/* Observed rate bar (solid) */}
|
||||
<rect x={margin.left} y={rowY + 4} width={obsWidth} height={barHeight - 8} fill="var(--cv-navy-600)" rx={2} opacity={0.9} />
|
||||
|
||||
{/* Model interval (dashed outline showing 90% range) */}
|
||||
<rect x={lowerX} y={rowY + 4} width={upperWidth} height={barHeight - 8} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
|
||||
|
||||
{/* Model median marker */}
|
||||
<line x1={medX} y1={rowY + 3} x2={medX} y2={rowY + barHeight - 3} stroke="var(--teal-600)" strokeWidth={2} />
|
||||
|
||||
{/* Observed rate label */}
|
||||
{r.observedRate > maxRate * 0.05 && (
|
||||
<text x={margin.left + obsWidth + 4} y={rowY + barHeight / 2 + 3} fontSize="0.6rem" fill="var(--cv-ink)">{r.observedRate.toFixed(1)}</text>
|
||||
)}
|
||||
|
||||
{/* Model label */}
|
||||
<text x={margin.left - 6} y={rowY + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{MODEL_LABELS[r.modelId] || r.modelId}</text>
|
||||
|
||||
{/* Y-axis gridline */}
|
||||
<line x1={margin.left - 4} y1={rowY + barHeight / 2 - 8} x2={margin.left} y2={rowY + barHeight / 2 - 8} stroke="var(--cv-rule)" strokeWidth={0.5} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis */}
|
||||
<line x1={margin.left} y1={height + modelResults.length * 16} x2={width - margin.right} y2={height + modelResults.length * 16} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
{[0, 0.5, 1].map((frac) => {
|
||||
const val = frac * maxRate
|
||||
return (
|
||||
<text key={`x-${val}`} x={margin.left + frac * (width - margin.left - margin.right)} y={height + modelResults.length * 24} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
|
||||
)
|
||||
})}
|
||||
<text x={margin.left + (width - margin.left - margin.right) / 2} y={height + modelResults.length * 30} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">Rate per 1,000 students</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height + modelResults.length * 8})`} style={{ fontSize: '0.65rem' }}>
|
||||
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
|
||||
<text x={18} y={10}>Observed rate</text>
|
||||
<line x1={140} y1={6} x2={156} y2={6} stroke="var(--teal-600)" strokeWidth={2} />
|
||||
<circle cx={148} cy={6} r={3} fill="var(--teal-600)" />
|
||||
<text x={162} y={10}>Model (median + 90% interval)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Solid blue = observed rate per 1,000 students. Teal outline + median line shows each model's posterior distribution (90% interval). Dashed lines indicate the range of plausible values according to each Bayesian specification.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getLabel(race, sex) {
|
||||
const r = race === 'WH' ? 'White' : race === 'BL' ? 'Black' : race === 'HI' ? 'Hispanic' : 'American Indian/Alaskan Native'
|
||||
const s = sex === 'F' ? 'Female' : 'Male'
|
||||
return `${r} ${s}`
|
||||
}
|
||||
@@ -1,111 +1,111 @@
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import { raceColor, OBSERVED_MARK_COLOR } from '../utils/colors.js'
|
||||
|
||||
/**
|
||||
* Chart 2: Arrest rate per 1,000 students by student group (most recent year).
|
||||
* Bar chart — observed rate vs. modeled median rate.
|
||||
* Bar = modeled median rate, colored by race. Dark diamond = observed rate,
|
||||
* overlaid at its position on the same bar. Sex is row grouping, not color.
|
||||
*/
|
||||
|
||||
const GROUP_COLORS = {
|
||||
'White Female': '#4A74B0', // navy-400
|
||||
'White Male': '#2E5590', // navy-500
|
||||
'Black Female': '#AB00AA', // plum-like (from social media posts)
|
||||
'Black Male': '#8B007F', // darker purple
|
||||
'Hispanic Female': '#1FAA4A', // green-ish (from wp_group_palette)
|
||||
'Hispanic Male': '#15903C',
|
||||
'AI/AN Female': '#D6271E', // red for American Indian emphasis
|
||||
'AI/AN Male': '#A6271D'
|
||||
}
|
||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||
const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
|
||||
const SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
||||
const ROW_HEIGHT = 22
|
||||
const SUBHEADER_HEIGHT = 20
|
||||
|
||||
export default function RateByGroupBar({ data }) {
|
||||
const width = 360
|
||||
const height = 300
|
||||
const margin = { top: 45, right: 30, bottom: 100, left: 60 }
|
||||
const margin = { top: 40, right: 30, bottom: 45, left: 90 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const barHeight = Math.min(16, (innerWidth * 0.7) / data.length)
|
||||
|
||||
// Y-axis scale — per 1k rate
|
||||
const maxRate = Math.max(...data.map(d => d.observedRate), ...data.map(d => d.modeledMedian || 0), 0.5)
|
||||
const bySex = SEX_ROWS
|
||||
.map(({ sex, label }) => ({
|
||||
sex,
|
||||
label,
|
||||
rows: RACE_ORDER.map((race) => data.find((d) => d.race === race && d.sex === sex)).filter(Boolean),
|
||||
}))
|
||||
.filter((g) => g.rows.length > 0)
|
||||
|
||||
const bodyHeight = bySex.reduce((sum, g) => sum + SUBHEADER_HEIGHT + g.rows.length * ROW_HEIGHT, 0)
|
||||
const height = margin.top + bodyHeight + margin.bottom
|
||||
|
||||
const maxRate = Math.max(...data.map((d) => Math.max(d.observedRate, d.modeledMedian || 0)), 0.5)
|
||||
const xScale = (val) => (val / maxRate) * innerWidth
|
||||
|
||||
let cursorY = margin.top
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Arrest rate by student group — 2021–22 (per 1,000)
|
||||
</h3>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxRate
|
||||
if (val > maxRate) return null
|
||||
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxRate) * (height - margin.top - margin.bottom))
|
||||
const x = margin.left + xScale(val)
|
||||
return (
|
||||
<g key={`yg-${frac}`}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
|
||||
stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={margin.left - 8} y={y + 4} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
|
||||
<g key={`xg-${frac}`}>
|
||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
|
||||
{val.toFixed(1)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
|
||||
Rate per 1,000 students
|
||||
</text>
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const y = margin.top + i * barHeight + 4
|
||||
const observedWidth = (d.observedRate / maxRate) * innerWidth
|
||||
const modeledWidth = ((d.modeledMedian || d.observedRate) / maxRate) * innerWidth
|
||||
{bySex.map((group) => {
|
||||
const subheaderY = cursorY
|
||||
cursorY += SUBHEADER_HEIGHT
|
||||
const rowEls = group.rows.map((d) => {
|
||||
const rowY = cursorY
|
||||
cursorY += ROW_HEIGHT
|
||||
const color = raceColor(d.race)
|
||||
const modeledWidth = xScale(d.modeledMedian || 0)
|
||||
const observedX = margin.left + xScale(d.observedRate)
|
||||
const midY = rowY + ROW_HEIGHT / 2
|
||||
|
||||
return (
|
||||
<g key={`${d.race}-${d.sex}`}>
|
||||
{/* Observed bar */}
|
||||
{d.observedArrests > 0 && (
|
||||
<>
|
||||
<rect x={margin.left} y={y + 2} width={observedWidth} height={barHeight - 4}
|
||||
fill="var(--cv-navy-600)" rx={2} />
|
||||
{/* Observed count label */}
|
||||
{d.observedRate > maxRate * 0.1 && (
|
||||
<text x={margin.left + observedWidth + 4} y={y + barHeight / 2 + 4}
|
||||
fontSize="0.65rem" fill="var(--cv-ink)">{d.observedArrests}</text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modeled median (dashed outline) */}
|
||||
{d.modeledMedian > 0 && d.modeledMedian !== d.observedRate * (d.stu_enroll || 1) / 1000 && (
|
||||
<rect x={margin.left} y={y + 2} width={modeledWidth} height={barHeight - 4}
|
||||
fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
|
||||
)}
|
||||
|
||||
{/* Group label */}
|
||||
<text x={margin.left - 8} y={y + barHeight / 2 + 4} textAnchor="end"
|
||||
fontSize="0.7rem" fill="var(--cv-ink)">{d.label}</text>
|
||||
<rect x={margin.left} y={rowY + 3} width={modeledWidth} height={ROW_HEIGHT - 6} fill={color} rx={2} />
|
||||
<rect
|
||||
x={observedX - 4} y={midY - 4} width={8} height={8}
|
||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||||
transform={`rotate(45 ${observedX} ${midY})`}
|
||||
/>
|
||||
<text x={margin.left - 8} y={midY + 4} textAnchor="end" fontSize="0.7rem" fill="var(--cv-ink)">
|
||||
{SHORT_RACE_LABEL[d.race] || d.race}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<g key={group.sex}>
|
||||
<text x={margin.left} y={subheaderY + 14} fontSize="0.72rem" fontWeight={700} fill="var(--cv-ink-2)">
|
||||
{group.label}
|
||||
</text>
|
||||
{rowEls}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 10} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)">Solid = observed · Dashed = modeled median</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height - 40})`}>
|
||||
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
|
||||
<text x={18} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Observed arrests</text>
|
||||
<rect x={140} y={0} width={12} height={12} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
|
||||
<text x={158} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Modeled median</text>
|
||||
</g>
|
||||
<text x={margin.left + innerWidth / 2} y={height - 8} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">
|
||||
Rate per 1,000 students
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<ChartLegend items={[
|
||||
...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({
|
||||
shape: 'swatch', color: raceColor(race), label: SHORT_RACE_LABEL[race],
|
||||
})),
|
||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||
]} />
|
||||
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Bars show observed arrests per 1,000 students. Dashed outlines show the Bayesian model's median estimate (unified_m2_mod).
|
||||
Bar length is the Bayesian model's median estimate (unified_m2_mod); the dark marker is the
|
||||
observed rate at that position.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
+138
-282
@@ -1,270 +1,39 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||||
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS } from '../utils/colors.js'
|
||||
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import ApproxNote from '../components/ApproxNote.jsx'
|
||||
|
||||
const MODEL_LABELS = {
|
||||
unified_m1_mod: 'One-year, baseline',
|
||||
unified_m2_mod: 'One-year + covariate',
|
||||
unified_m3_mod: 'Three-year, baseline',
|
||||
unified_m4_mod: 'Three-year + covariate',
|
||||
/**
|
||||
* Chart 5: Modeled posterior density per race×sex group, faceted 2×2 by
|
||||
* model quadrant (matches whitepaper-fig-clark-density-1.png). Plain SVG —
|
||||
* no D3, no fake normal draws: each ridge is drawn as 60 analytic points
|
||||
* from fitSkewedInterval/densityCurve, which is already smooth without a
|
||||
* binning/curveBasis smoothing pass.
|
||||
*/
|
||||
|
||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||
const SEX_ORDER = ['F', 'M']
|
||||
const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
|
||||
|
||||
function buildGroupRow(row) {
|
||||
const enroll = row.stu_enroll || 0
|
||||
const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0
|
||||
const rateMedian = (row.rate_median || 0) * 1000
|
||||
const rateLower = (row.rate_lower || 0) * 1000
|
||||
const rateUpper = (row.rate_upper || 0) * 1000
|
||||
return {
|
||||
race: row.race,
|
||||
sex: row.sex,
|
||||
observedRate,
|
||||
fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }),
|
||||
}
|
||||
}
|
||||
|
||||
const RACE_LABELS = {
|
||||
WH: 'White',
|
||||
BL: 'Black',
|
||||
HI: 'Hispanic',
|
||||
AM: 'American Indian / Alaska Native'
|
||||
}
|
||||
export default function RateDensityRidgeline({ quadData }) {
|
||||
const hasData = quadData && Object.values(quadData).some((rows) => (rows || []).length > 0)
|
||||
|
||||
export default function RateDensityRidgeline({ quadData, rateByGroup }) {
|
||||
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
|
||||
const svgRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!quadData || !selectedModel || !quadData[selectedModel]) return
|
||||
|
||||
const rows = quadData[selectedModel] || []
|
||||
|
||||
// Get unique groups for this model (race × sex)
|
||||
const groupKeys = [...new Set(rows.map(r => `${r.race}-${r.sex}`))]
|
||||
|
||||
// Build data: for each group, estimate density from the interval
|
||||
const plotData = groupKeys.map(key => {
|
||||
const row = rows.find(r => `${r.race}-${r.sex}` === key)
|
||||
if (!row) return null
|
||||
|
||||
const race = row.race || 'WH'
|
||||
const sex = row.sex || 'F'
|
||||
const label = `${RACE_LABELS[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`
|
||||
|
||||
// Convert rate estimates to per-1000 scale
|
||||
const enroll = row.stu_enroll || 1
|
||||
const obsArrests = row.observed_arrests || 0
|
||||
const observedRate = enroll > 0 ? (obsArrests / enroll) * 1000 : 0
|
||||
|
||||
// Model rate estimates in per-1k units
|
||||
const rateMedian = ((row.rate_median || 0) * 1000) / (enroll > 0 ? 1 : 1)
|
||||
const rateLower = ((row.rate_lower || 0) * 1000) / (enroll > 0 ? 1 : 1)
|
||||
const rateUpper = ((row.rate_upper || 0) * 1000) / (enroll > 0 ? 1 : 1)
|
||||
|
||||
// Generate draws from normal approximation of the posterior interval
|
||||
// Use 90% CI: sd = (upper - lower) / 3.29
|
||||
const intervalWidth = Math.abs(rateUpper - rateLower) || 0.1
|
||||
const sd = intervalWidth / 3.29
|
||||
|
||||
// Generate a larger sample for smoother density estimation
|
||||
const draws = Array.from({ length: 500 }, () =>
|
||||
Math.max(0, d3.randomNormal(rateMedian, sd)() )
|
||||
).sort(d3.ascending)
|
||||
|
||||
return { key, label, race, sex, rateMedian, observedRate, draws }
|
||||
}).filter(Boolean)
|
||||
|
||||
// Set up dimensions
|
||||
const container = svgRef.current?.parentElement
|
||||
const width = Math.min(500, container?.clientWidth || 500)
|
||||
const height = Math.max(320, plotData.length * 60 + 80)
|
||||
const margin = { top: 40, right: 30, bottom: 70, left: 90 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
// Clear previous
|
||||
d3.select(svgRef.current).selectAll('*').remove()
|
||||
|
||||
const svg = d3.select(svgRef.current)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
// Compute x-scale domain from all data (draws + observed rates)
|
||||
const allDrawValues = plotData.flatMap(d => d.draws).filter(v => v <= 25)
|
||||
const maxRate = Math.min(Math.max(...allDrawValues, ...plotData.map(d => d.observedRate), 0.1), 25)
|
||||
|
||||
const xScale = d3.scaleLinear()
|
||||
.domain([0, maxRate])
|
||||
.range([margin.left, margin.left + innerWidth])
|
||||
|
||||
// Y-scale: one band per group (race × sex)
|
||||
const yScale = d3.scaleBand()
|
||||
.domain(plotData.map(d => d.label))
|
||||
.range([margin.top, margin.top + innerHeight])
|
||||
.padding(0.25)
|
||||
|
||||
// Title
|
||||
svg.append('text')
|
||||
.attr('x', margin.left + innerWidth / 2)
|
||||
.attr('y', 18)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.7rem')
|
||||
.attr('fill', '#666')
|
||||
.text(`Posterior predicted arrests per 1,000 students — ${MODEL_LABELS[selectedModel]}`)
|
||||
|
||||
// Grid lines
|
||||
svg.append('g')
|
||||
.attr('class', 'grid')
|
||||
.selectAll('line')
|
||||
.data(xScale.ticks(8))
|
||||
.join('line')
|
||||
.attr('x1', d => xScale(d))
|
||||
.attr('x2', d => xScale(d))
|
||||
.attr('y1', margin.top)
|
||||
.attr('y2', margin.top + innerHeight)
|
||||
.attr('stroke', '#f0f0f0')
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
// X-axis
|
||||
svg.append('g')
|
||||
.attr('transform', `translate(0,${margin.top + innerHeight})`)
|
||||
.call(d3.axisBottom(xScale).ticks(8))
|
||||
.call(g => g.select('.domain').attr('stroke', '#ddd'))
|
||||
.call(g => g.selectAll('.tick line').remove())
|
||||
.call(g => g.selectAll('.tick text')
|
||||
.attr('font-size', '0.65rem')
|
||||
.attr('fill', '#888'))
|
||||
|
||||
// X-axis label
|
||||
svg.append('text')
|
||||
.attr('x', margin.left + innerWidth / 2)
|
||||
.attr('y', height - 12)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.7rem')
|
||||
.attr('fill', '#888')
|
||||
.text('Arrests per 1,000 students')
|
||||
|
||||
// Y-axis
|
||||
svg.append('g')
|
||||
.attr('transform', `translate(${margin.left},0)`)
|
||||
.call(d3.axisLeft(yScale).tickSize(0))
|
||||
.call(g => g.select('.domain').remove())
|
||||
.call(g => g.selectAll('.tick text')
|
||||
.attr('font-size', '0.65rem')
|
||||
.attr('fill', '#444'))
|
||||
|
||||
// Colors matching Civilytics palette
|
||||
const fillColor = '#000a9b' // navy
|
||||
const obsColor = '#c92d0e' // danger red
|
||||
|
||||
// Draw each ridge (one per race × sex group)
|
||||
plotData.forEach(d => {
|
||||
const yTop = yScale(d.label) || 0
|
||||
const bandwidth = yScale.bandwidth() || 40
|
||||
const baselineY = yTop + bandwidth * 0.15 // bottom of the ridge area
|
||||
const peakHeight = bandwidth * 0.8 // available height for density
|
||||
|
||||
// Create histogram bins from draws, then smooth into a ridge shape
|
||||
const nBins = 40
|
||||
const binWidth = maxRate / nBins
|
||||
const bins = new Array(nBins).fill(0)
|
||||
|
||||
d.draws.forEach(val => {
|
||||
if (val <= maxRate) {
|
||||
const idx = Math.min(Math.floor(val / binWidth), nBins - 1)
|
||||
if (idx >= 0) bins[idx]++
|
||||
}
|
||||
})
|
||||
|
||||
// Find peak count for scaling
|
||||
const maxCount = d3.max(bins) || 1
|
||||
const heightScale = peakHeight * 0.85 / maxCount
|
||||
|
||||
// Build the top edge points of the ridge (smoothed with curveBasis)
|
||||
const topPoints = bins.map((count, i) => {
|
||||
if (count === 0) return null
|
||||
const xMid = margin.left + ((i + 0.5) * binWidth / maxRate) * innerWidth
|
||||
// Ridge grows upward from baseline: higher count → taller ridge (lower y value)
|
||||
const yVal = baselineY - Math.min(count * heightScale, peakHeight)
|
||||
return [xMid, yVal]
|
||||
}).filter(Boolean)
|
||||
|
||||
if (topPoints.length < 2) return
|
||||
|
||||
// Smooth the top edge using basis interpolation
|
||||
const lineGen = d3.line()
|
||||
.curve(d3.curveBasis)
|
||||
.x(d => d[0])
|
||||
.y(d => d[1])
|
||||
|
||||
const smoothedTop = lineGen(topPoints) || ''
|
||||
|
||||
if (!smoothedTop) return
|
||||
|
||||
// Build the complete area path: bottom edge + smoothed top + close
|
||||
// The fill goes from baselineY down to peakHeight (upward in SVG coords, so y decreases)
|
||||
const firstX = margin.left
|
||||
const lastX = margin.left + innerWidth
|
||||
|
||||
let ridgePath = `M${firstX},${baselineY}` // start at bottom-left of ridge
|
||||
ridgePath += `L${topPoints[0][0]},${baselineY}` // line to first data x (along baseline)
|
||||
ridgePath += smoothedTop.substring(1) // append the smoothed top curve (skip 'M')
|
||||
ridgePath += `L${lastX},${baselineY}Z` // close back along bottom
|
||||
|
||||
svg.append('path')
|
||||
.attr('d', ridgePath)
|
||||
.attr('fill', fillColor)
|
||||
.attr('opacity', 0.55)
|
||||
.attr('stroke', 'white')
|
||||
.attr('stroke-width', 0.5)
|
||||
|
||||
// Observed rate marker — a diamond sitting on top of each ridge
|
||||
if (d.observedRate > 0 && d.observedRate <= maxRate) {
|
||||
const obsX = xScale(d.observedRate)
|
||||
const obsY = baselineY - peakHeight * 0.2 // position above the density
|
||||
|
||||
// Diamond shape (like ggplot2's shape=18)
|
||||
svg.append('polygon')
|
||||
.attr('points', [
|
||||
`${obsX},${obsY - 4}`,
|
||||
`${obsX + 4},${obsY}`,
|
||||
`${obsX},${obsY + 4}`,
|
||||
`${obsX - 4},${obsY}`
|
||||
].join(' '))
|
||||
.attr('fill', obsColor)
|
||||
|
||||
// Label the observed rate value below the diamond
|
||||
svg.append('text')
|
||||
.attr('x', obsX)
|
||||
.attr('y', yTop + bandwidth * 0.65)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.6rem')
|
||||
.attr('fill', '#888')
|
||||
.text(d.observedRate.toFixed(1))
|
||||
}
|
||||
})
|
||||
|
||||
// Legend
|
||||
const legendY = height - 25
|
||||
svg.append('rect')
|
||||
.attr('x', margin.left)
|
||||
.attr('y', legendY)
|
||||
.attr('width', 12)
|
||||
.attr('height', 8)
|
||||
.attr('fill', fillColor)
|
||||
.attr('opacity', 0.55)
|
||||
svg.append('text')
|
||||
.attr('x', margin.left + 16)
|
||||
.attr('y', legendY + 7)
|
||||
.attr('font-size', '0.62rem')
|
||||
.attr('fill', '#888')
|
||||
.text('Modeled posterior (90% interval)')
|
||||
|
||||
// Observed diamond legend
|
||||
svg.append('polygon')
|
||||
.attr('points', [
|
||||
`${margin.left + 140},${legendY}`,
|
||||
`${margin.left + 144},${legendY + 5}`,
|
||||
`${margin.left + 140},${legendY + 10}`,
|
||||
`${margin.left + 136},${legendY + 5}`
|
||||
].join(' '))
|
||||
.attr('fill', obsColor)
|
||||
svg.append('text')
|
||||
.attr('x', margin.left + 150)
|
||||
.attr('y', legendY + 7)
|
||||
.attr('font-size', '0.62rem')
|
||||
.attr('fill', '#888')
|
||||
.text('Observed rate (diamond)')
|
||||
|
||||
}, [quadData, selectedModel])
|
||||
|
||||
if (!quadData || Object.keys(quadData).length === 0) {
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
@@ -275,34 +44,121 @@ export default function RateDensityRidgeline({ quadData, rateByGroup }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Shared x-domain across all four quadrant panels, so they're directly comparable.
|
||||
const allUpper = []
|
||||
const allObserved = []
|
||||
MODEL_QUADRANTS.forEach((q) => {
|
||||
(quadData[q.model] || []).forEach((row) => {
|
||||
allUpper.push((row.rate_upper || 0) * 1000)
|
||||
const enroll = row.stu_enroll || 0
|
||||
if (enroll > 0) allObserved.push(((row.observed_arrests || 0) / enroll) * 1000)
|
||||
})
|
||||
})
|
||||
const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Predicted arrest rates by student group
|
||||
</h3>
|
||||
<ApproxNote />
|
||||
|
||||
{/* Model selector */}
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
style={{ marginBottom: 'var(--space-2)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}
|
||||
>
|
||||
{Object.entries(MODEL_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)', marginTop: 'var(--space-1)' }}>
|
||||
{MODEL_QUADRANTS.map((q) => (
|
||||
<RidgePanel key={q.model} q={q} rows={quadData[q.model] || []} maxRate={maxRate} />
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* D3 Ridgeline */}
|
||||
<div style={{ width: '100%', overflowX: 'auto' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', minWidth: 420, height: 'auto' }} />
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
<strong>Density ridges</strong> show the posterior distribution of predicted arrests per 1,000 students.
|
||||
<strong>Diamonds</strong> mark observed rates for each group (the raw data point being modeled).
|
||||
</p>
|
||||
<ChartLegend items={[
|
||||
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
|
||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||
]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RidgePanel({ q, rows, maxRate }) {
|
||||
const groups = []
|
||||
SEX_ORDER.forEach((sex) => {
|
||||
RACE_ORDER.forEach((race) => {
|
||||
const row = rows.find((r) => r.race === race && r.sex === sex)
|
||||
if (row) groups.push(buildGroupRow(row))
|
||||
})
|
||||
})
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-2)' }}>
|
||||
<p style={{ fontSize: '0.65rem', color: 'var(--cv-ink-3)' }}>{q.label} — no data for the selected groups</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const width = 300
|
||||
const rowHeight = 46
|
||||
const margin = { top: 24, right: 16, bottom: 26, left: 56 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const height = margin.top + groups.length * rowHeight + margin.bottom
|
||||
|
||||
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
||||
|
||||
const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }))
|
||||
const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9)
|
||||
const peakHeight = rowHeight * 0.85
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
<text x={width / 2} y={14} textAnchor="middle" fontSize="0.65rem" fontWeight={600} fill="var(--cv-ink-2)">
|
||||
{q.label}
|
||||
</text>
|
||||
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxRate
|
||||
const x = xScale(val)
|
||||
return (
|
||||
<g key={frac}>
|
||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
||||
{val.toFixed(0)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{groups.map((g, i) => {
|
||||
const rowTop = margin.top + i * rowHeight
|
||||
const baselineY = rowTop + rowHeight * 0.9
|
||||
const curve = curves[i]
|
||||
const color = raceColor(g.race)
|
||||
|
||||
const topPath = curve
|
||||
.map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`)
|
||||
.join(' ')
|
||||
const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z`
|
||||
|
||||
const obsX = xScale(Math.min(g.observedRate, maxRate))
|
||||
const obsY = baselineY - peakHeight * 0.15
|
||||
|
||||
return (
|
||||
<g key={`${g.race}-${g.sex}`}>
|
||||
<text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink)">
|
||||
{SHORT_RACE_LABEL[g.race] || g.race} {g.sex}
|
||||
</text>
|
||||
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
|
||||
<rect
|
||||
x={obsX - 4} y={obsY - 4} width={8} height={8}
|
||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||||
transform={`rotate(45 ${obsX} ${obsY})`}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
<text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
||||
Arrests per 1,000 students
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DISTRIBUTION_APPROX_NOTE } from '../utils/distributionApprox.js'
|
||||
|
||||
/** Consistent caption for any chart that renders an approximated distribution shape. */
|
||||
export default function ApproxNote() {
|
||||
return (
|
||||
<p style={{ fontSize: '0.78rem', fontStyle: 'italic', color: 'var(--cv-ink-3)', margin: '0.25rem 0 0' }}>
|
||||
{DISTRIBUTION_APPROX_NOTE}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Plain-HTML chart legend — replaces every chart's hand-rolled SVG legend.
|
||||
* Both of the app's stray-`}` CSS bugs lived inside hand-written SVG legend
|
||||
* `fill="..."` strings; keeping legends out of SVG removes that bug class.
|
||||
*
|
||||
* items: [{ color, label, shape: 'swatch' | 'diamond' | 'line' }]
|
||||
*/
|
||||
export default function ChartLegend({ items }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.25rem', marginTop: 'var(--space-2)' }}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<LegendMark shape={item.shape} color={item.color} />
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--cv-ink-2)' }}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LegendMark({ shape, color }) {
|
||||
if (shape === 'diamond') {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '9px',
|
||||
height: '9px',
|
||||
background: color,
|
||||
transform: 'rotate(45deg)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (shape === 'line') {
|
||||
return <span style={{ display: 'inline-block', width: '16px', height: '3px', background: color, borderRadius: '2px', flexShrink: 0 }} />
|
||||
}
|
||||
return <span style={{ display: 'inline-block', width: '11px', height: '11px', background: color, borderRadius: '3px', flexShrink: 0 }} />
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as api from '../hooks/useApi.js'
|
||||
import useGroupFilter, { filterRowsByGroup } from '../hooks/useGroupFilter.js'
|
||||
import GroupFilterBar from './GroupFilterBar.jsx'
|
||||
import { groupLabel } from '../utils/colors.js'
|
||||
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
|
||||
import RateByGroupBar from '../charts/RateByGroupBar.jsx'
|
||||
import DistrictVsNational from '../charts/DistrictVsNational.jsx'
|
||||
@@ -7,16 +10,18 @@ import ModelDrawsComparison from '../charts/ModelDrawsComparison.jsx'
|
||||
import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx'
|
||||
import ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
|
||||
|
||||
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
||||
|
||||
/**
|
||||
* ChartPanel — 6 charts in a responsive grid.
|
||||
* Fetches all required data on mount (since LoadingAnimation already loaded it, but we need to re-fetch structured).
|
||||
* ChartPanel — 6 charts in a single-column layout, all scoped by one shared
|
||||
* student-group filter (defaults to all 8 groups selected).
|
||||
*/
|
||||
export default function ChartPanel({ district, state }) {
|
||||
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model/group
|
||||
const [nationalRates, setNationalRates] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const filter = useGroupFilter()
|
||||
|
||||
// Use centralized API client for consistent error handling and CORS proxy support.
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -29,25 +34,20 @@ export default function ChartPanel({ district, state }) {
|
||||
setNationalRates(nat)
|
||||
|
||||
// Fetch all 3 waves × three-year model (unified_m3_mod) for Chart 1 + Charts 2-3
|
||||
// Three-year models return observed data for all waves
|
||||
const waves = ['21-22', '17-18', '15-16']
|
||||
const waveData = {}
|
||||
await Promise.all(waves.map(async (year) => {
|
||||
try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {}
|
||||
}))
|
||||
|
||||
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, but we batch by model)
|
||||
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, batched by model)
|
||||
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
|
||||
const quadData = {}
|
||||
await Promise.all(quadrants.map(async (model) => {
|
||||
try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {}
|
||||
}))
|
||||
|
||||
// Raw posterior draws are not available via browser API (only bulk Parquet shards)
|
||||
// Falls back to interval-based rendering in charts
|
||||
const modelDraws = null
|
||||
|
||||
setData({ waveData, quadData, modelDraws })
|
||||
setData({ waveData, quadData })
|
||||
} catch (err) {
|
||||
console.error('ChartPanel data fetch failed:', err)
|
||||
} finally {
|
||||
@@ -62,48 +62,67 @@ export default function ChartPanel({ district, state }) {
|
||||
return <LoadingCharts />
|
||||
}
|
||||
|
||||
// ——— Derive chart-specific data from the fetched estimates ———
|
||||
const mostRecent = data.waveData['21-22'] || []
|
||||
// ——— Every derived dataset below is scoped to the selected student groups ———
|
||||
const mostRecent = filterRowsByGroup(data.waveData['21-22'] || [], filter)
|
||||
|
||||
// Chart 1: Arrests over time by wave (total + rate per 1k labels)
|
||||
const timeSeriesData = ['15-16', '17-18', '21-22'].map(year => {
|
||||
const yearRows = data.waveData[year] || []
|
||||
// Sum observed_arrests and stu_enroll across all groups for this wave
|
||||
const totalArrests = (yearRows || []).reduce((sum, r) => sum + (r.observed_arrests || 0), 0)
|
||||
const totalEnroll = (yearRows || []).reduce((sum, r) => sum + (r.stu_enroll || 0), 0)
|
||||
return { year, label: `20${year.replace('-', '-')}`, arrests: totalArrests, enroll: totalEnroll }
|
||||
// Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range
|
||||
const timeSeriesData = ALL_WAVES.map((year) => {
|
||||
const yearRows = filterRowsByGroup(data.waveData[year] || [], filter)
|
||||
return {
|
||||
year,
|
||||
label: `20${year.replace('-', '-')}`,
|
||||
arrests: yearRows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0),
|
||||
enroll: yearRows.reduce((sum, r) => sum + (r.stu_enroll || 0), 0),
|
||||
modeledMedian: yearRows.reduce((sum, r) => sum + (r.count_median || 0), 0),
|
||||
modeledLower: yearRows.reduce((sum, r) => sum + (r.count_lower || 0), 0),
|
||||
modeledUpper: yearRows.reduce((sum, r) => sum + (r.count_upper || 0), 0),
|
||||
}
|
||||
})
|
||||
|
||||
// Chart 2: Rate by student group (most recent wave, per 1k)
|
||||
const rateByGroup = mostRecent.map(r => ({
|
||||
race: r.race, sex: r.sex, label: getStudentLabel(r.race, r.sex),
|
||||
// Charts 2-3: rate by student group (most recent wave, per 1k), with modeled + observed + interval
|
||||
const rateByGroup = mostRecent
|
||||
.map((r) => ({
|
||||
race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex),
|
||||
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
|
||||
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
|
||||
rateLower: (r.rate_lower || 0) * 1000,
|
||||
rateUpper: (r.rate_upper || 0) * 1000,
|
||||
observedArrests: r.observed_arrests || 0,
|
||||
enrollment: r.stu_enroll || 0
|
||||
})).sort((a, b) => b.observedRate - a.observedRate)
|
||||
enrollment: r.stu_enroll || 0,
|
||||
}))
|
||||
.sort((a, b) => b.observedRate - a.observedRate)
|
||||
|
||||
// Chart 3: District vs national — top student group by rate
|
||||
const topGroup = rateByGroup[0] || { race: 'BL', sex: 'M' }
|
||||
const natKey = `${topGroup.race}_${topGroup.sex}`
|
||||
const nationalTopRate = nationalRates?.rates_per_1000?.[natKey] || 0
|
||||
const topGroup = rateByGroup[0] || null
|
||||
const natKey = topGroup ? `${topGroup.race}_${topGroup.sex}` : null
|
||||
const nationalTopRate = (natKey && nationalRates?.rates_per_1000?.[natKey]) || 0
|
||||
|
||||
// Chart 4-6 use quadData (4 models × 8 groups)
|
||||
const chart4Data = { timeSeries: mostRecent, waves: data.waveData, modelDraws: data.modelDraws }
|
||||
const chart5Data = data.quadData || {} // all 4 quadrant model results
|
||||
const chart6Data = { rates: rateByGroup, nationalRates: nationalRates?.rates_per_1000 || {} }
|
||||
// Charts 4-6: all 4 quadrant models, each filtered to the selected groups
|
||||
const quadDataFiltered = Object.fromEntries(
|
||||
Object.entries(data.quadData || {}).map(([model, rows]) => [model, filterRowsByGroup(rows, filter)])
|
||||
)
|
||||
const waveDataFiltered = Object.fromEntries(
|
||||
ALL_WAVES.map((year) => [year, filterRowsByGroup(data.waveData[year] || [], filter)])
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
|
||||
{/* Chart panel header */}
|
||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<div style={{ marginBottom: 'var(--space-3)' }}>
|
||||
<span className="eyebrow">District estimates — Bayesian model comparison</span>
|
||||
<h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}>
|
||||
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Single column layout: one chart per row with full-width cards */}
|
||||
<div style={{ maxWidth: '70rem', marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
<GroupFilterBar filter={filter} />
|
||||
</div>
|
||||
|
||||
{filter.selected.size === 0 ? (
|
||||
<p style={{ color: 'var(--cv-ink-3)', maxWidth: '70rem', margin: '0 auto' }}>
|
||||
Select at least one student group above to see charts.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -117,15 +136,18 @@ export default function ChartPanel({ district, state }) {
|
||||
|
||||
<RateByGroupBar data={rateByGroup} />
|
||||
|
||||
{topGroup && (
|
||||
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
|
||||
)}
|
||||
|
||||
{/* Charts 4–6: Bayesian model distributions */}
|
||||
<ModelDrawsComparison data={{ ...chart4Data, quadData: chart5Data }} waves={['15-16', '17-18', '21-22']} districtName={district.lea_name} />
|
||||
<ModelDrawsComparison data={{ waves: waveDataFiltered, quadData: quadDataFiltered }} districtName={district.lea_name} />
|
||||
|
||||
<RateDensityRidgeline quadData={chart5Data} rateByGroup={rateByGroup} />
|
||||
<RateDensityRidgeline quadData={quadDataFiltered} />
|
||||
|
||||
<ExceedanceProbability rateByGroup={rateByGroup} nationalRates={nationalRates?.rates_per_1000 || {}} />
|
||||
<ExceedanceProbability quadData={quadDataFiltered} nationalRates={nationalRates?.rates_per_1000 || {}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Methodology footer */}
|
||||
<div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}>
|
||||
@@ -140,16 +162,11 @@ export default function ChartPanel({ district, state }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getStudentLabel(race, sex) {
|
||||
const labels = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'American Indian/Alaskan Native' }
|
||||
return `${labels[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`.replace('American Indian/Alaskan Native', 'AI/AN')
|
||||
}
|
||||
|
||||
function LoadingCharts() {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)', textAlign: 'center' }}>
|
||||
<span className="eyebrow">Preparing charts</span>
|
||||
<p style={{ color: 'var(--cv-ink-2)' }}>Organizing data across all model specifications…</p>
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>Organizing data across all model specifications…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { raceColor, RACE_LABELS } from '../utils/colors.js'
|
||||
|
||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||
const SEX_ROWS = [
|
||||
{ sex: 'F', label: 'Female' },
|
||||
{ sex: 'M', label: 'Male' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Student-group filter: two rows (Female/Male) of 4 race-colored toggle
|
||||
* chips. Sex is row position, not color — race owns the color channel, and
|
||||
* a chip's color never changes when siblings are toggled.
|
||||
*/
|
||||
export default function GroupFilterBar({ filter }) {
|
||||
return (
|
||||
<div style={{ margin: '0 0 var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
|
||||
<span className="eyebrow" style={{ marginBottom: 0 }}>Student groups</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn-outline" onClick={filter.selectAll} disabled={filter.allSelected}>
|
||||
Select all
|
||||
</button>
|
||||
<button type="button" className="btn-outline" onClick={filter.selectNone} disabled={filter.selected.size === 0}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: 'var(--space-1)' }}>
|
||||
{SEX_ROWS.map(({ sex, label }) => (
|
||||
<div key={sex} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--cv-ink-3)', width: '3.5rem', flexShrink: 0 }}>{label}</span>
|
||||
{RACE_ORDER.map((race) => {
|
||||
const active = filter.isSelected(race, sex)
|
||||
const color = raceColor(race)
|
||||
return (
|
||||
<button
|
||||
key={race}
|
||||
type="button"
|
||||
onClick={() => filter.toggle(race, sex)}
|
||||
aria-pressed={active}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: `1.5px solid ${color}`,
|
||||
background: active ? color : 'transparent',
|
||||
color: active ? '#fff' : 'var(--cv-ink-2)',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 120ms ease, color 120ms ease',
|
||||
}}
|
||||
>
|
||||
{RACE_LABELS[race]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { STUDENT_GROUPS } from './useApi.js'
|
||||
import { groupKey } from '../utils/groupKey.js'
|
||||
|
||||
/**
|
||||
* Shared student-group filter — defaults to all 8 groups selected. Lives
|
||||
* once in ChartPanel and threads down to every chart, so "select which
|
||||
* groups to focus on" behaves consistently everywhere instead of being
|
||||
* reimplemented per chart.
|
||||
*/
|
||||
export default function useGroupFilter(groups = STUDENT_GROUPS) {
|
||||
const allKeys = useMemo(() => groups.map((g) => groupKey(g.race, g.sex)), [groups])
|
||||
const [selected, setSelected] = useState(() => new Set(allKeys))
|
||||
|
||||
const isSelected = useCallback((race, sex) => selected.has(groupKey(race, sex)), [selected])
|
||||
|
||||
const toggle = useCallback((race, sex) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const key = groupKey(race, sex)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => setSelected(new Set(allKeys)), [allKeys])
|
||||
const selectNone = useCallback(() => setSelected(new Set()), [])
|
||||
|
||||
return { groups, selected, isSelected, toggle, selectAll, selectNone, allSelected: selected.size === allKeys.length }
|
||||
}
|
||||
|
||||
/** Filters a rows array (each row has .race/.sex) down to the selected groups. */
|
||||
export function filterRowsByGroup(rows, filter) {
|
||||
return (rows || []).filter((r) => filter.isSelected(r.race, r.sex))
|
||||
}
|
||||
+11
-5
@@ -70,13 +70,19 @@
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* — Chart-specific colors — */
|
||||
--chart-modeled: #000a9bff; /* dark indigo-blue (from wp_model_palette) */
|
||||
--chart-frequentist: #858585bb; /* gray (from wp_model_palette) */
|
||||
--chart-observed: #A6271D; /* red for observed data points */
|
||||
/* — Race categorical palette (validated, see src/utils/colors.js) — */
|
||||
--race-wh: #3D6FC4;
|
||||
--race-bl: #C98A2A;
|
||||
--race-hi: #9C3F86;
|
||||
--race-am: #3D9A6B;
|
||||
|
||||
/* — Chart-specific colors — aliased onto the tokens charts actually use */
|
||||
--chart-modeled: var(--cv-navy-600);
|
||||
--chart-frequentist: var(--cv-ink-4);
|
||||
--chart-observed: var(--cv-ink);
|
||||
|
||||
/* — Model quadrant colors — */
|
||||
--model-one-year-baseline: var(--navy-600);
|
||||
--model-one-year-baseline: var(--cv-navy-600);
|
||||
--model-three-year-baseline: var(--teal-600);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Fixed categorical race palette — validated with the dataviz skill's
|
||||
* validate_palette.js (passes lightness band, chroma floor, CVD separation
|
||||
* ΔE 12.8, normal-vision floor ΔE 26.4; gold's sub-3:1 contrast WARN is
|
||||
* mitigated by always-visible direct labels/legend in every chart that uses
|
||||
* it). Order is fixed (WH, BL, HI, AM) and must not be re-derived from array
|
||||
* position — re-run the validator before changing any of these hex values:
|
||||
* node scripts/validate_palette.js "#3D6FC4,#C98A2A,#9C3F86,#3D9A6B" --mode light
|
||||
*
|
||||
* Sex (F/M) is deliberately NOT a second hue — it's encoded by row/facet
|
||||
* position in every chart. Observed-vs-modeled is deliberately NOT a second
|
||||
* hue either — it's encoded by mark type (diamond vs. filled bar/density).
|
||||
*/
|
||||
export const RACE_COLORS = { WH: '#3D6FC4', BL: '#C98A2A', HI: '#9C3F86', AM: '#3D9A6B' }
|
||||
|
||||
export const RACE_LABELS = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'American Indian / Alaska Native' }
|
||||
|
||||
// Mark-type colors for the observed-vs-modeled convention. These are used
|
||||
// only where a chart has no race facet of its own (aggregate totals) or
|
||||
// needs a neutral third "reference/benchmark" role distinct from both.
|
||||
export const OBSERVED_MARK_COLOR = 'var(--cv-ink)'
|
||||
export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)'
|
||||
export const REFERENCE_GRAY = 'var(--cv-ink-4)'
|
||||
|
||||
export function raceColor(race) {
|
||||
return RACE_COLORS[race] || REFERENCE_GRAY
|
||||
}
|
||||
|
||||
export function groupLabel(race, sex) {
|
||||
return `${RACE_LABELS[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Approximates a distribution shape from summary statistics only
|
||||
* (median + interval bounds) when raw posterior draws aren't available
|
||||
* client-side. This is NOT the true posterior — always pair its use with
|
||||
* DISTRIBUTION_APPROX_NOTE (rendered via <ApproxNote />).
|
||||
*
|
||||
* Arrest rates/counts are right-skewed, not normal, so a single symmetric
|
||||
* normal (the old approach) systematically misrepresents the shape. This
|
||||
* fits two normal halves — one on each side of the median, each sized to
|
||||
* its own interval bound — and splices their CDFs at the median. Because
|
||||
* each half's CDF independently spans [0, 0.5] or [0.5, 1], the join is
|
||||
* exactly the median by construction (cdf(median) === 0.5 always), unlike
|
||||
* the classical two-piece-normal parameterization, which biases the median
|
||||
* away from the split point whenever the two sigmas differ. Do not "fix"
|
||||
* this toward that textbook formula — losing median-exactness is the bug,
|
||||
* not a missing feature.
|
||||
*/
|
||||
|
||||
const SQRT_2PI = Math.sqrt(2 * Math.PI)
|
||||
const MIN_ABS_SIGMA = 1e-3
|
||||
|
||||
function standardNormalPdf(z) {
|
||||
return Math.exp(-0.5 * z * z) / SQRT_2PI
|
||||
}
|
||||
|
||||
// Abramowitz & Stegun 7.1.26 approximation of erf, ~1.5e-7 max error.
|
||||
function erf(x) {
|
||||
const sign = x < 0 ? -1 : 1
|
||||
const ax = Math.abs(x)
|
||||
const a1 = 0.254829592
|
||||
const a2 = -0.284496736
|
||||
const a3 = 1.421413741
|
||||
const a4 = -1.453152027
|
||||
const a5 = 1.061405429
|
||||
const p = 0.3275911
|
||||
const t = 1 / (1 + p * ax)
|
||||
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-ax * ax)
|
||||
return sign * y
|
||||
}
|
||||
|
||||
function standardNormalCdf(z) {
|
||||
return 0.5 * (1 + erf(z / Math.SQRT2))
|
||||
}
|
||||
|
||||
// Peter Acklam's rational approximation of the inverse standard normal CDF
|
||||
// (probit), relative error < 1.15e-9. Supports arbitrary intervalMass.
|
||||
function probit(p) {
|
||||
const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
|
||||
const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01]
|
||||
const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
|
||||
const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00]
|
||||
const pLow = 0.02425
|
||||
const pHigh = 1 - pLow
|
||||
|
||||
if (p < pLow) {
|
||||
const q = Math.sqrt(-2 * Math.log(p))
|
||||
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
||||
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
|
||||
}
|
||||
if (p <= pHigh) {
|
||||
const q = p - 0.5
|
||||
const r = q * q
|
||||
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
|
||||
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
|
||||
}
|
||||
const q = Math.sqrt(-2 * Math.log(1 - p))
|
||||
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
||||
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{median:number, lower:number, upper:number, intervalMass?:number, floorAtZero?:boolean}} p
|
||||
* intervalMass: fraction of probability covered by [lower, upper] — the
|
||||
* API's rate/count interval bounds are a 90% interval, so default 0.90.
|
||||
* @returns {{ median:number, sigmaLeft:number, sigmaRight:number, pdf:(x:number)=>number, cdf:(x:number)=>number }}
|
||||
*/
|
||||
export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.90, floorAtZero = true }) {
|
||||
const z = probit((1 + intervalMass) / 2)
|
||||
|
||||
let sigmaLeft = (median - lower) / z
|
||||
let sigmaRight = (upper - median) / z
|
||||
|
||||
if (!(sigmaLeft > 0)) sigmaLeft = Math.max(sigmaRight * 0.15, MIN_ABS_SIGMA)
|
||||
if (!(sigmaRight > 0)) sigmaRight = Math.max(sigmaLeft * 0.15, MIN_ABS_SIGMA)
|
||||
sigmaLeft = Math.max(sigmaLeft, MIN_ABS_SIGMA)
|
||||
sigmaRight = Math.max(sigmaRight, MIN_ABS_SIGMA)
|
||||
|
||||
function pdf(x) {
|
||||
if (floorAtZero && x < 0) return 0
|
||||
const sigma = x < median ? sigmaLeft : sigmaRight
|
||||
return standardNormalPdf((x - median) / sigma) / sigma
|
||||
}
|
||||
|
||||
function cdf(x) {
|
||||
if (floorAtZero && x < 0) return 0
|
||||
const sigma = x <= median ? sigmaLeft : sigmaRight
|
||||
return standardNormalCdf((x - median) / sigma)
|
||||
}
|
||||
|
||||
return { median, sigmaLeft, sigmaRight, pdf, cdf }
|
||||
}
|
||||
|
||||
/** n evenly spaced {x,y} points of the fitted pdf, for drawing a smooth curve. */
|
||||
export function densityCurve(fit, { min = 0, max, n = 60 } = {}) {
|
||||
const hi = max ?? fit.median + fit.sigmaRight * 3.5
|
||||
const lo = Math.min(min, fit.median - fit.sigmaLeft * 0.1)
|
||||
const step = (hi - lo) / (n - 1)
|
||||
const points = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = lo + step * i
|
||||
points.push({ x, y: fit.pdf(x) })
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/** P(X > threshold) under the fitted approximation. */
|
||||
export function probabilityExceeds(fit, threshold) {
|
||||
return 1 - fit.cdf(threshold)
|
||||
}
|
||||
|
||||
export const DISTRIBUTION_APPROX_NOTE =
|
||||
'Distribution shape estimated from interval bounds — not raw posterior draws.'
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Single source of truth for the race/sex group key format (matches the
|
||||
* `RACE_SEX` keys used in national_rates.json), so charts and filters agree.
|
||||
*/
|
||||
|
||||
export function groupKey(race, sex) {
|
||||
return `${race}_${sex}`
|
||||
}
|
||||
|
||||
export function parseGroupKey(key) {
|
||||
const [race, sex] = key.split('_')
|
||||
return { race, sex }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||||
|
||||
/** model id -> display label, derived from the single source of truth in useApi.js. */
|
||||
export const MODEL_QUADRANT_LABEL = Object.fromEntries(
|
||||
MODEL_QUADRANTS.map((q) => [q.model, q.label])
|
||||
)
|
||||
Reference in New Issue
Block a user