Redesign chart visualizations to match Civilytics white-paper style
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:
2026-08-10 18:45:37 -04:00
co-authored by Claude Sonnet 5
parent 6c8e1e8409
commit 67caa02fe2
19 changed files with 910 additions and 1580 deletions
-461
View File
@@ -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
View File
@@ -25,8 +25,5 @@
"react-dom": "^19.2.8",
"vite": "^8.2.1"
},
"type": "module",
"dependencies": {
"d3": "^7.9.0"
}
"type": "module"
}
+39 -29
View File
@@ -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>
)
+57 -56
View File
@@ -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}
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{nationalRate.toFixed(2)}</text>
{/* 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>
</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>
<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>
</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>
)
+90 -124
View File
@@ -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 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 rows = (quadData && quadData[selectedModel]) || []
const computed = rows
.map((row) => {
const natKey = groupKey(row.race, row.sex)
const nationalRate = nationalRates[natKey] || 0
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>
<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} />
{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%' }}>
<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))
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>
</g>
)
})}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
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>
</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})`}>
P(district rate &gt; national)
</text>
<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 &gt; national)
</text>
{/* Bars */}
{computed.map((d, i) => {
const barHeight = Math.min(22, (innerWidth * 0.65) / computed.length)
const y = margin.top + i * (barHeight + 4)
const probWidth = d.probExceeds * innerWidth
{/* 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" />
// 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
{computed.map((d, i) => {
const barHeight = Math.min(22, (plotHeight * 0.9) / computed.length)
const y = margin.top + i * (barHeight + 4)
const probWidth = d.probExceeds * innerWidth
return (
<g key={`${d.race}-${d.sex}`}>
{/* Bar */}
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
let barColor
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)'
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)'
else barColor = 'var(--cv-danger)'
{/* 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>
)}
return (
<g key={`${d.race}-${d.sex}`}>
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
{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 - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">
{d.label}
</text>
</g>
)
})}
{/* Group label */}
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{d.label}</text>
<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>
{/* 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" />
)}
<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)">&lt; 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)">&gt; 80%</text>
</g>
</svg>
)}
{/* 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>
</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)">&lt; 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)">&gt; 80% (likely)</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')
+88 -330
View File
@@ -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 => {
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)
}
})
export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) {
const waveData = waves.map((year) => {
const rows = (data.waves && data.waves[year]) || []
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 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(...allArrestValues, 1), 50) // Cap at 50 for readability
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 &amp; 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)
// 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']
function QuadrantPanel({ q, waveData, quadModels, maxArrests }) {
const isThreeYear = q.model.includes('m3') || q.model.includes('m4')
const showYears = isThreeYear ? ALL_WAVES : ['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 innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
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>
)
}
}
-144
View File
@@ -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}`
}
+78 -78
View File
@@ -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})`}>
{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}`}>
<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>
)
})}
<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>
{/* 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
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>
</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>
</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>
)
+139 -283
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
}
+40
View File
@@ -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 }} />
}
+83 -66
View File
@@ -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) {}
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) {}
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,71 +62,93 @@ 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),
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
observedArrests: r.observed_arrests || 0,
enrollment: r.stu_enroll || 0
})).sort((a, b) => b.observedRate - a.observedRate)
// 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)
// 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={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-5)',
maxWidth: '70rem',
marginLeft: 'auto',
marginRight: 'auto'
}}>
{/* Charts 1–3: Observed & descriptive */}
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
<RateByGroupBar data={rateByGroup} />
<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} />
<RateDensityRidgeline quadData={chart5Data} rateByGroup={rateByGroup} />
<ExceedanceProbability rateByGroup={rateByGroup} nationalRates={nationalRates?.rates_per_1000 || {}} />
<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',
gap: 'var(--space-5)',
maxWidth: '70rem',
marginLeft: 'auto',
marginRight: 'auto'
}}>
{/* Charts 1–3: Observed & descriptive */}
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
<RateByGroupBar data={rateByGroup} />
{topGroup && (
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
)}
{/* Charts 4–6: Bayesian model distributions */}
<ModelDrawsComparison data={{ waves: waveDataFiltered, quadData: quadDataFiltered }} districtName={district.lea_name} />
<RateDensityRidgeline quadData={quadDataFiltered} />
<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)' }}>
<span className="eyebrow">Methodology</span>
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+36
View File
@@ -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
View File
@@ -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);
}
+31
View File
@@ -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'}`
}
+122
View File
@@ -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.'
+13
View File
@@ -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 }
}
+6
View File
@@ -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])
)