Add D3 and new ridgeline chart matching R aesthetic
Deploy to git-pages / deploy (push) Successful in 19s
Deploy to git-pages / deploy (push) Successful in 19s
- Install d3@7 - Add fetchDistrictDraws API function (for future use with raw draws) - Create RateDensityRidgeline component using D3.js - Density ridges showing posterior distributions per race group - Diamond markers for observed rates (matching R design) - Dropdown to switch between model specifications - Matches Civilytics color palette (navy fill, danger diamonds)
This commit is contained in:
Generated
+462
-1
@@ -7,7 +7,10 @@
|
||||
"": {
|
||||
"name": "crdc-arrests-demo",
|
||||
"version": "0.1.0",
|
||||
"license": "ISC",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||
"eslint": "^8.57.1",
|
||||
@@ -835,6 +838,15 @@
|
||||
"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",
|
||||
@@ -857,6 +869,407 @@
|
||||
"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",
|
||||
@@ -882,6 +1295,15 @@
|
||||
"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",
|
||||
@@ -1260,6 +1682,18 @@
|
||||
"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",
|
||||
@@ -1316,6 +1750,15 @@
|
||||
"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",
|
||||
@@ -2024,6 +2467,12 @@
|
||||
"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",
|
||||
@@ -2081,6 +2530,18 @@
|
||||
"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",
|
||||
|
||||
+4
-1
@@ -25,5 +25,8 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"vite": "^8.2.1"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"d3": "^7.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
const RACE_LABELS = {
|
||||
WH: 'White',
|
||||
BL: 'Black',
|
||||
HI: 'Hispanic',
|
||||
AM: 'American Indian/\nAlaska Native'
|
||||
}
|
||||
|
||||
export default function RateDensityRidgeline({ quadData, rateByGroup }) {
|
||||
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
|
||||
const svgRef = useRef(null)
|
||||
|
||||
// Colors matching Civilytics palette
|
||||
const colors = {
|
||||
navy: '#000a9b',
|
||||
teal: '#0791b6',
|
||||
danger: '#c92d0e',
|
||||
ink: '#222222'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!quadData || !selectedModel) return
|
||||
|
||||
const rows = quadData[selectedModel] || []
|
||||
|
||||
// Get unique groups for this model
|
||||
const groups = [...new Map(rows.map(r => [`${r.race}-${r.sex}`, {
|
||||
race: r.race,
|
||||
sex: r.sex,
|
||||
label: `${RACE_LABELS[r.race] || r.race} ${r.sex === 'F' ? 'Female' : 'Male'}`
|
||||
}])).values()]
|
||||
|
||||
// Build data for ridgeline
|
||||
const plotData = groups.map(g => {
|
||||
const row = rows.find(r => r.race === g.race && r.sex === g.sex)
|
||||
if (!row) return null
|
||||
|
||||
// Generate histogram from draws or use rate bounds to approximate
|
||||
const enroll = row.stu_enroll || 1
|
||||
|
||||
// Create density-like data from the interval
|
||||
const rateMedian = (row.rate_median || 0) * 1000
|
||||
const rateLower = (row.rate_lower || 0) * 1000
|
||||
const rateUpper = (row.rate_upper || 0) * 1000
|
||||
|
||||
// Generate synthetic histogram from the interval (normal approximation)
|
||||
const sd = (rateUpper - rateLower) / 3.29 // 90% interval
|
||||
const draws = Array.from({ length: 100 }, () =>
|
||||
Math.max(0, d3.randomNormal(rateMedian, sd || 0.1)())
|
||||
)
|
||||
|
||||
// Observed rate
|
||||
const observedRate = (row.observed_arrests || 0) / ((enroll || 1) / 1000)
|
||||
|
||||
return {
|
||||
...g,
|
||||
draws: draws.sort(d3.ascending),
|
||||
observedRate,
|
||||
rateMedian
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
// Observed rates from rateByGroup for comparison (single model only)
|
||||
const observedData = groups.map(g => {
|
||||
const groupData = rateByGroup.find(r => r.race === g.race && r.sex === g.sex)
|
||||
return groupData ? { ...g, observedRate: groupData.observedRate } : null
|
||||
}).filter(Boolean)
|
||||
|
||||
// Set up dimensions
|
||||
const container = svgRef.current.parentElement
|
||||
const width = Math.min(500, container?.clientWidth || 500)
|
||||
const height = Math.max(300, groups.length * 50 + 80)
|
||||
const margin = { top: 40, right: 30, bottom: 60, left: 80 }
|
||||
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}`)
|
||||
|
||||
// Create scales
|
||||
const allRates = plotData.flatMap(d => d.draws).concat(observedData.map(d => d.observedRate))
|
||||
const xMax = Math.min(Math.max(...allRates, 0), 20) || 5 // Cap at 20 per 1k for readability
|
||||
|
||||
const xScale = d3.scaleLinear()
|
||||
.domain([0, xMax])
|
||||
.range([margin.left, margin.left + innerWidth])
|
||||
|
||||
const yScale = d3.scaleBand()
|
||||
.domain(plotData.map(d => d.label))
|
||||
.range([margin.top, margin.top + innerHeight])
|
||||
.padding(0.3)
|
||||
|
||||
// X-axis
|
||||
svg.append('g')
|
||||
.attr('transform', `translate(0,${margin.top + innerHeight})`)
|
||||
.call(d3.axisBottom(xScale).ticks(10))
|
||||
.call(g => g.select('.domain').attr('stroke', '#ddd'))
|
||||
.call(g => g.selectAll('.tick line').attr('stroke', '#eee'))
|
||||
.call(g => g.selectAll('.tick text').attr('fill', '#666'))
|
||||
|
||||
// X-axis label
|
||||
svg.append('text')
|
||||
.attr('x', margin.left + innerWidth / 2)
|
||||
.attr('y', height - 10)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.75rem')
|
||||
.attr('fill', '#666')
|
||||
.text('Predicted arrests per 1,000 students')
|
||||
|
||||
// Grid lines
|
||||
svg.append('g')
|
||||
.attr('class', 'grid')
|
||||
.selectAll('line')
|
||||
.data(xScale.ticks(10))
|
||||
.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)
|
||||
|
||||
// Generate density polygons for each group
|
||||
plotData.forEach((d, i) => {
|
||||
const yPos = yScale(d.label)
|
||||
const rowHeight = yScale.bandwidth()
|
||||
|
||||
if (!yPos) return
|
||||
|
||||
// Create histogram bins
|
||||
const binGenerator = d3.bin()
|
||||
.domain(xScale.domain())
|
||||
.thresholds(30)
|
||||
|
||||
const bins = binGenerator(d.draws)
|
||||
const maxCount = d3.max(bins, b => b.length)
|
||||
|
||||
// Area generator
|
||||
const area = d3.area()
|
||||
.x(b => xScale((b.x0 + b.x1) / 2))
|
||||
.y0(yPos)
|
||||
.y1(yPos)
|
||||
.curve(d3.curveBasis)
|
||||
|
||||
// Scale heights
|
||||
const heightScale = d3.scaleLinear()
|
||||
.domain([0, maxCount])
|
||||
.range([rowHeight * 0.1, rowHeight * 0.9])
|
||||
|
||||
// Draw the ridgeline
|
||||
svg.append('path')
|
||||
.datum(bins)
|
||||
.attr('fill', colors.navy)
|
||||
.attr('opacity', 0.6)
|
||||
.attr('stroke', 'white')
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('d', d => area.bisector ?
|
||||
d3.area()
|
||||
.x(b => xScale((b.x0 + b.x1) / 2))
|
||||
.y0(yPos)
|
||||
.y1(b => yPos + heightScale(b.length))
|
||||
.curve(d3.curveBasis)(d)
|
||||
: null
|
||||
)
|
||||
|
||||
// Fix: manual polygon construction for each bin
|
||||
const points = []
|
||||
bins.forEach(b => {
|
||||
if (b.length > 0) {
|
||||
const xMid = (b.x0 + b.x1) / 2
|
||||
points.push([xScale(xMid), yPos])
|
||||
points.push([xScale(xMid), yPos + heightScale(b.length)])
|
||||
}
|
||||
})
|
||||
|
||||
// Add top edge
|
||||
for (let i = bins.length - 1; i >= 0; i--) {
|
||||
const b = bins[i]
|
||||
if (b.length > 0) {
|
||||
const xMid = (b.x0 + b.x1) / 2
|
||||
points.push([xScale(xMid), yPos])
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length > 0) {
|
||||
svg.append('path')
|
||||
.attr('d', `M${points.map(p => p.join(',')).join('L')}Z`)
|
||||
.attr('fill', colors.navy)
|
||||
.attr('opacity', 0.55)
|
||||
.attr('stroke', 'white')
|
||||
.attr('stroke-width', 0.5)
|
||||
}
|
||||
|
||||
// Observed rate marker (diamond shape)
|
||||
if (d.observedRate > 0 && d.observedRate <= xMax) {
|
||||
const obsY = yPos + rowHeight / 2
|
||||
|
||||
// Diamond polygon
|
||||
svg.append('path')
|
||||
.attr('d', `M${xScale(d.observedRate)},${obsY - 6} L${xScale(d.observedRate) + 5},${obsY} L${xScale(d.observedRate)},${obsY + 6} L${xScale(d.observedRate) - 5},${obsY} Z`)
|
||||
.attr('fill', colors.danger)
|
||||
|
||||
// Label for observed rate
|
||||
svg.append('text')
|
||||
.attr('x', xScale(d.observedRate))
|
||||
.attr('y', obsY - 10)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '0.65rem')
|
||||
.attr('fill', colors.danger)
|
||||
.attr('font-weight', 600)
|
||||
.text(d.observedRate.toFixed(1))
|
||||
}
|
||||
})
|
||||
|
||||
// Y-axis labels
|
||||
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.75rem')
|
||||
.attr('fill', colors.ink)
|
||||
.attr('font-weight', 500))
|
||||
|
||||
// Adjust y-axis label text (handle newlines)
|
||||
svg.selectAll('.tick text')
|
||||
.text(d => d.replace('/\n', ' '))
|
||||
|
||||
// Legend
|
||||
const legend = svg.append('g')
|
||||
.attr('transform', `translate(${margin.left + innerWidth - 120}, ${height - 35})`)
|
||||
|
||||
// Diamond legend
|
||||
legend.append('path')
|
||||
.attr('d', 'M0,-4 L3,0 L0,4 L-3,0 Z')
|
||||
.attr('fill', colors.danger)
|
||||
legend.append('text')
|
||||
.attr('x', 8)
|
||||
.attr('y', 2)
|
||||
.attr('font-size', '0.65rem')
|
||||
.attr('fill', '#666')
|
||||
.text('Observed rate')
|
||||
|
||||
// Ridge legend
|
||||
legend.append('rect')
|
||||
.attr('x', 70)
|
||||
.attr('y', -8)
|
||||
.attr('width', 12)
|
||||
.attr('height', 8)
|
||||
.attr('fill', colors.navy)
|
||||
.attr('opacity', 0.55)
|
||||
legend.append('text')
|
||||
.attr('x', 86)
|
||||
.attr('y', -2)
|
||||
.attr('font-size', '0.65rem')
|
||||
.attr('fill', '#666')
|
||||
.text('Modeled (90%)')
|
||||
|
||||
}, [quadData, selectedModel, rateByGroup])
|
||||
|
||||
if (!quadData || Object.keys(quadData).length === 0) {
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Predicted arrest rates by student group
|
||||
</h3>
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Predicted arrest rates by student group
|
||||
</h3>
|
||||
|
||||
{/* Model selector */}
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
style={{ marginBottom: 'var(--space-2)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}
|
||||
>
|
||||
{Object.entries(MODEL_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* D3 Ridgeline */}
|
||||
<div style={{ width: '100%', overflowX: 'auto' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', minWidth: 400, height: 'auto' }} />
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
<strong>Density ridges</strong> show the posterior distribution of predicted arrests per 1,000 students.
|
||||
<strong>Diamonds</strong> mark the observed rate for each group (the raw data point the model aims to improve).
|
||||
Using 90% intervals from the {MODEL_LABELS[selectedModel]} model.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
|
||||
import RateByGroupBar from '../charts/RateByGroupBar.jsx'
|
||||
import DistrictVsNational from '../charts/DistrictVsNational.jsx'
|
||||
import ModelDrawsComparison from '../charts/ModelDrawsComparison.jsx'
|
||||
import ObservedRateDensity from '../charts/ObservedRateDensity.jsx'
|
||||
import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx'
|
||||
import ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
|
||||
|
||||
/**
|
||||
@@ -118,7 +118,7 @@ export default function ChartPanel({ district, state }) {
|
||||
{/* Charts 4–6: Bayesian model distributions */}
|
||||
<ModelDrawsComparison data={{ ...chart4Data, quadData: chart5Data }} waves={['15-16', '17-18', '21-22']} districtName={district.lea_name} />
|
||||
|
||||
<ObservedRateDensity quadData={chart5Data} />
|
||||
<RateDensityRidgeline quadData={chart5Data} rateByGroup={rateByGroup} />
|
||||
|
||||
<ExceedanceProbability rateByGroup={rateByGroup} nationalRates={nationalRates?.rates_per_1000 || {}} />
|
||||
</div>
|
||||
|
||||
@@ -134,3 +134,15 @@ export async function fetchDrawLocation(state, race, sex, year, model) {
|
||||
|
||||
return apiFetch(`/draws?${params}`)
|
||||
}
|
||||
|
||||
/** GET /estimates/{leaid}/draws?model=X&year=Y — fetch posterior draws for a district */
|
||||
export async function fetchDistrictDraws(leaid, options = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (options.model) params.set('model', options.model)
|
||||
if (options.year) params.set('year', options.year)
|
||||
if (options.race) params.set('race', options.race)
|
||||
if (options.sex) params.set('sex', options.sex)
|
||||
|
||||
const qs = params.toString()
|
||||
return apiFetch(`/estimates/${leaid}/draws${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user