diff --git a/package-lock.json b/package-lock.json
index 0c10471..ef55c1c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 1eafd6d..ca50d03 100644
--- a/package.json
+++ b/package.json
@@ -25,8 +25,5 @@
"react-dom": "^19.2.8",
"vite": "^8.2.1"
},
- "type": "module",
- "dependencies": {
- "d3": "^7.9.0"
- }
+ "type": "module"
}
diff --git a/src/charts/ArrestsOverTime.jsx b/src/charts/ArrestsOverTime.jsx
index 4d8fcab..15dbbd8 100644
--- a/src/charts/ArrestsOverTime.jsx
+++ b/src/charts/ArrestsOverTime.jsx
@@ -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 (
- {/* Chart title */}
- Observed arrests over time by CRDC wave
+ Arrests over time — observed vs. modeled
- {/* Background */}
- {/* 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 */}
- Total arrests (raw count)
+ Total arrests (count)
- {/* X-axis labels */}
{data.map((d, i) => (
@@ -66,20 +61,34 @@ export default function ArrestsOverTime({ data, districtName }) {
))}
- {/* X-axis label */}
CRDC wave
- {/* Line connecting points */}
+ {/* Observed line */}
{data.length > 1 && (
`${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 (
+
+
+
+
+ )
+ })}
+
+ {/* 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 (
- {/* Point circle */}
-
-
- {/* Rate label above point */}
+
{ratePerK}
{districtName}
- {/* Footer note */}
+
+
- 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.
)
diff --git a/src/charts/DistrictVsNational.jsx b/src/charts/DistrictVsNational.jsx
index 8210e76..54f078d 100644
--- a/src/charts/DistrictVsNational.jsx
+++ b/src/charts/DistrictVsNational.jsx
@@ -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 (
- {/* Chart title */}
District vs. national rate — highest group
- {/* Background */}
-
+
- {/* 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 (
-
+
{val.toFixed(1)}
)
})}
- {/* Y-axis label */}
-
+
Rate per 1,000 students
- {/* Two side-by-side bars: District vs National */}
-
- {/* District bar */}
-
- {districtRate.toFixed(2)}
+ {/* District bar — modeled median, race-colored, with 90% interval whisker */}
+
+
+ {/* Observed diamond, overlaid on the district bar */}
+
- {/* National bar */}
-
- {nationalRate.toFixed(2)}
+ {/* National bar — neutral reference */}
+
+ {nationalRate.toFixed(2)}
- {/* X-axis labels */}
- District
- National
+ District
+ National
- {/* Group label */}
-
- {topGroup.label || 'Top group'} — observed rate per 1,000
-
-
-
- {/* Ratio annotation */}
-
-
- {districtRate > 0 ? `${(districtRate / nationalRate).toFixed(1)}×` : ''}
-
-
+
+ {topGroup.label || 'Top group'}
+
- {/* Note */}
+
+
- {topGroup.label || 'Highest-rate group'} in this district has a rate of{' '}
- {districtRate.toFixed(2)} per 1,000 vs. the national average of{' '}
+ {topGroup.label || 'Highest-rate group'} in this district has an observed rate of{' '}
+ {districtObserved.toFixed(2)} per 1,000 vs. the national average of{' '}
{nationalRate.toFixed(2)} . 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.
)
diff --git a/src/charts/ExceedanceProbability.jsx b/src/charts/ExceedanceProbability.jsx
index 779645e..6f35964 100644
--- a/src/charts/ExceedanceProbability.jsx
+++ b/src/charts/ExceedanceProbability.jsx
@@ -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 (
- {/* Chart title */}
Probability district exceeds national rate — by student group
+
- {/* Model selector (advanced toggle) */}
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) => (
{opt.label}
))}
-
- {/* Background */}
-
+ {computed.length === 0 ? (
+ No model data available for the selected groups.
+ ) : (
+
+
- {/* 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 (
-
-
- {frac.toFixed(2)}
-
- )
- })}
+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => {
+ const y = margin.top + plotHeight - frac * plotHeight
+ return (
+
+
+ {frac.toFixed(2)}
+
+ )
+ })}
- {/* Y-axis label */}
-
- P(district rate > national)
-
+
+ P(district rate > national)
+
- {/* 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 */}
+
- // 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 (
-
- {/* Bar */}
-
+ 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 && (
- {Math.round(d.probExceeds * 100)}%
- )}
+ return (
+
+
+ {d.probExceeds > 0.05 && (
+
+ {Math.round(d.probExceeds * 100)}%
+
+ )}
+
+ {d.label}
+
+
+ )
+ })}
- {/* Group label */}
- {d.label}
+
+ Probability (higher = district more likely to exceed national rate)
+
- {/* National rate reference line at 50% */}
- {i === Math.floor(computed.length / 2) && (
-
- )}
+
+
+ < 50%
+
+ 50–80%
+
+ > 80%
+
+
+ )}
- {/* National rate annotation */}
-
- national = {d.nationalRate.toFixed(2)}/1k
-
-
- )
- })}
-
- {/* X-axis label */}
-
- Probability (higher = district more likely to exceed national rate)
-
-
- {/* Legend */}
-
-
- < 50% (not likely)
-
- 50–80% (possible)
-
- > 80% (likely)
-
-
-
- {/* Summary note */}
- 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.
)
}
-
-// 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')
diff --git a/src/charts/ModelDrawsComparison.jsx b/src/charts/ModelDrawsComparison.jsx
index a5ccc16..106e5f8 100644
--- a/src/charts/ModelDrawsComparison.jsx
+++ b/src/charts/ModelDrawsComparison.jsx
@@ -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 (
- {/* Chart title */}
- Model predictions vs. observed — by wave & model type
+ Model predictions vs. observed — by wave & model specification
- {/* Four small multiples (one per quadrant) */}
- {QUADRANT_LABELS.map((q) => (
-
+ {MODEL_QUADRANTS.map((q) => (
+
))}
- {/* Legend */}
-
-
-
- Observed arrests
-
-
-
- One-year model (90% interval)
-
-
-
- Three-year model (90% HPD)
-
-
+
- {/* Note */}
- Red circles = 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}.` : ''}
)
}
-/** 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 (
-
+
+
+ {q.label}
+
+
+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => {
+ const val = frac * maxArrests
+ const y = yScale(val)
+ return (
+
+
+ {Math.round(val)}
+
+ )
+ })}
+
+ {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 (
+
+
+ {WAVE_LABELS[year]}
+
+
+ {yearRow && (
+ <>
+
+
+ >
+ )}
+
+
+
+ )
+ })}
+
)
-}
\ No newline at end of file
+}
diff --git a/src/charts/ObservedRateDensity.jsx b/src/charts/ObservedRateDensity.jsx
deleted file mode 100644
index 3e4823f..0000000
--- a/src/charts/ObservedRateDensity.jsx
+++ /dev/null
@@ -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 (
-
-
- Observed rate vs. model distribution — by student group
-
-
No model data available.
-
- )
- }
-
- // 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 (
-
- {/* Chart title */}
-
- Observed rate vs. model distribution — {currentGroup?.label || 'Select a group'}
-
-
- {/* Group selector */}
-
setExpandedGroup(Number(e.target.value))}
- style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
- {groups.map((g, i) => (
- {g.label}
- ))}
-
-
- {/* Ridgeline-style visualization (proxy using interval bars) */}
-
- {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 (
-
- {/* Background band */}
-
-
- {/* Observed rate bar (solid) */}
-
-
- {/* Model interval (dashed outline showing 90% range) */}
-
-
- {/* Model median marker */}
-
-
- {/* Observed rate label */}
- {r.observedRate > maxRate * 0.05 && (
- {r.observedRate.toFixed(1)}
- )}
-
- {/* Model label */}
- {MODEL_LABELS[r.modelId] || r.modelId}
-
- {/* Y-axis gridline */}
-
-
- )
- })}
-
- {/* X-axis */}
-
- {[0, 0.5, 1].map((frac) => {
- const val = frac * maxRate
- return (
- {val.toFixed(1)}
- )
- })}
- Rate per 1,000 students
-
- {/* Legend */}
-
-
- Observed rate
-
-
- Model (median + 90% interval)
-
-
-
- {/* Note */}
-
- 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.
-
-
- )
-}
-
-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}`
-}
diff --git a/src/charts/RateByGroupBar.jsx b/src/charts/RateByGroupBar.jsx
index 59ec0da..bf32a86 100644
--- a/src/charts/RateByGroupBar.jsx
+++ b/src/charts/RateByGroupBar.jsx
@@ -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 (
- {/* Chart title */}
Arrest rate by student group — 2021–22 (per 1,000)
- {/* Background */}
-
+
- {/* 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 (
-
-
- {val.toFixed(1)}
+
+
+
+ {val.toFixed(1)}
+
)
})}
- {/* Y-axis label */}
-
+ {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 (
+
+
+
+
+ {SHORT_RACE_LABEL[d.race] || d.race}
+
+
+ )
+ })
+ return (
+
+
+ {group.label}
+
+ {rowEls}
+
+ )
+ })}
+
+
Rate per 1,000 students
-
- {/* 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 (
-
- {/* Observed bar */}
- {d.observedArrests > 0 && (
- <>
-
- {/* Observed count label */}
- {d.observedRate > maxRate * 0.1 && (
- {d.observedArrests}
- )}
- >
- )}
-
- {/* Modeled median (dashed outline) */}
- {d.modeledMedian > 0 && d.modeledMedian !== d.observedRate * (d.stu_enroll || 1) / 1000 && (
-
- )}
-
- {/* Group label */}
- {d.label}
-
- )
- })}
-
- {/* X-axis label */}
- Solid = observed · Dashed = modeled median
-
- {/* Legend */}
-
-
- Observed arrests
-
- Modeled median
-
- {/* Note */}
+
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' },
+ ]} />
+
- 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.
)
diff --git a/src/charts/RateDensityRidgeline.jsx b/src/charts/RateDensityRidgeline.jsx
index dec9321..723c0d0 100644
--- a/src/charts/RateDensityRidgeline.jsx
+++ b/src/charts/RateDensityRidgeline.jsx
@@ -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 (
@@ -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 (
- {/* Chart title */}
Predicted arrest rates by student group
+
- {/* Model selector */}
-
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]) => (
- {label}
+
+ {MODEL_QUADRANTS.map((q) => (
+
))}
-
-
- {/* D3 Ridgeline */}
-
-
- {/* Note */}
-
- Density ridges show the posterior distribution of predicted arrests per 1,000 students.
- Diamonds mark observed rates for each group (the raw data point being modeled).
-
+
({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
+ { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
+ ]} />
)
-}
\ No newline at end of file
+}
+
+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 (
+
+
{q.label} — no data for the selected groups
+
+ )
+ }
+
+ 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 (
+
+
+
+ {q.label}
+
+
+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => {
+ const val = frac * maxRate
+ const x = xScale(val)
+ return (
+
+
+
+ {val.toFixed(0)}
+
+
+ )
+ })}
+
+ {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 (
+
+
+ {SHORT_RACE_LABEL[g.race] || g.race} {g.sex}
+
+
+
+
+ )
+ })}
+
+
+ Arrests per 1,000 students
+
+
+
+ )
+}
diff --git a/src/components/ApproxNote.jsx b/src/components/ApproxNote.jsx
new file mode 100644
index 0000000..4053727
--- /dev/null
+++ b/src/components/ApproxNote.jsx
@@ -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 (
+
+ {DISTRIBUTION_APPROX_NOTE}
+
+ )
+}
diff --git a/src/components/ChartLegend.jsx b/src/components/ChartLegend.jsx
new file mode 100644
index 0000000..b1cdcf7
--- /dev/null
+++ b/src/components/ChartLegend.jsx
@@ -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 (
+
+ {items.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+ )
+}
+
+function LegendMark({ shape, color }) {
+ if (shape === 'diamond') {
+ return (
+
+ )
+ }
+ if (shape === 'line') {
+ return
+ }
+ return
+}
diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx
index d7cbc6b..59cb87d 100644
--- a/src/components/ChartPanel.jsx
+++ b/src/components/ChartPanel.jsx
@@ -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
}
- // ——— 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 (
{/* Chart panel header */}
-
+
District estimates — Bayesian model comparison
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
- {/* Single column layout: one chart per row with full-width cards */}
-
- {/* Charts 1–3: Observed & descriptive */}
-
-
-
-
-
-
- {/* Charts 4–6: Bayesian model distributions */}
-
-
-
-
-
+
+
+ {filter.selected.size === 0 ? (
+
+ Select at least one student group above to see charts.
+
+ ) : (
+
+ {/* Charts 1–3: Observed & descriptive */}
+
+
+
+
+ {topGroup && (
+
+ )}
+
+ {/* Charts 4–6: Bayesian model distributions */}
+
+
+
+
+
+
+ )}
+
{/* Methodology footer */}
Methodology
@@ -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 (
Preparing charts
-
Organizing data across all model specifications…
+
Organizing data across all model specifications…
)
}
diff --git a/src/components/GroupFilterBar.jsx b/src/components/GroupFilterBar.jsx
new file mode 100644
index 0000000..9af3b0b
--- /dev/null
+++ b/src/components/GroupFilterBar.jsx
@@ -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 (
+
+
+
Student groups
+
+
+ Select all
+
+
+ Clear
+
+
+
+
+
+ {SEX_ROWS.map(({ sex, label }) => (
+
+ {label}
+ {RACE_ORDER.map((race) => {
+ const active = filter.isSelected(race, sex)
+ const color = raceColor(race)
+ return (
+ 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]}
+
+ )
+ })}
+
+ ))}
+
+
+ )
+}
diff --git a/src/hooks/useGroupFilter.js b/src/hooks/useGroupFilter.js
new file mode 100644
index 0000000..57d2eb9
--- /dev/null
+++ b/src/hooks/useGroupFilter.js
@@ -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))
+}
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index ba62da8..c3ee0f1 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -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);
}
diff --git a/src/utils/colors.js b/src/utils/colors.js
new file mode 100644
index 0000000..d288d17
--- /dev/null
+++ b/src/utils/colors.js
@@ -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'}`
+}
diff --git a/src/utils/distributionApprox.js b/src/utils/distributionApprox.js
new file mode 100644
index 0000000..468e513
--- /dev/null
+++ b/src/utils/distributionApprox.js
@@ -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
).
+ *
+ * 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.'
diff --git a/src/utils/groupKey.js b/src/utils/groupKey.js
new file mode 100644
index 0000000..20fddf9
--- /dev/null
+++ b/src/utils/groupKey.js
@@ -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 }
+}
diff --git a/src/utils/labels.js b/src/utils/labels.js
new file mode 100644
index 0000000..264e0a8
--- /dev/null
+++ b/src/utils/labels.js
@@ -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])
+)