From 67caa02fe2c0d884716a1b523f68b7a310bc7643 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 10 Aug 2026 18:45:37 -0400 Subject: [PATCH] Redesign chart visualizations to match Civilytics white-paper style 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 --- package-lock.json | 461 --------------------------- package.json | 5 +- src/charts/ArrestsOverTime.jsx | 68 ++-- src/charts/DistrictVsNational.jsx | 113 +++---- src/charts/ExceedanceProbability.jsx | 214 ++++++------- src/charts/ModelDrawsComparison.jsx | 418 +++++------------------- src/charts/ObservedRateDensity.jsx | 144 --------- src/charts/RateByGroupBar.jsx | 156 ++++----- src/charts/RateDensityRidgeline.jsx | 422 ++++++++---------------- src/components/ApproxNote.jsx | 10 + src/components/ChartLegend.jsx | 40 +++ src/components/ChartPanel.jsx | 149 +++++---- src/components/GroupFilterBar.jsx | 66 ++++ src/hooks/useGroupFilter.js | 36 +++ src/styles/tokens.css | 16 +- src/utils/colors.js | 31 ++ src/utils/distributionApprox.js | 122 +++++++ src/utils/groupKey.js | 13 + src/utils/labels.js | 6 + 19 files changed, 910 insertions(+), 1580 deletions(-) delete mode 100644 src/charts/ObservedRateDensity.jsx create mode 100644 src/components/ApproxNote.jsx create mode 100644 src/components/ChartLegend.jsx create mode 100644 src/components/GroupFilterBar.jsx create mode 100644 src/hooks/useGroupFilter.js create mode 100644 src/utils/colors.js create mode 100644 src/utils/distributionApprox.js create mode 100644 src/utils/groupKey.js create mode 100644 src/utils/labels.js 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) */} - - {/* 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 */} - - - {/* 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 */} - - - {/* 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 +
+ + +
+
+ +
+ {SEX_ROWS.map(({ sex, label }) => ( +
+ {label} + {RACE_ORDER.map((race) => { + const active = filter.isSelected(race, sex) + const color = raceColor(race) + return ( + + ) + })} +
+ ))} +
+
+ ) +} 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]) +)