Publish the redrawn interface
Browse files
README.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: redrawn
|
| 3 |
+
emoji: 🗺️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: Redraw the district lines and the answer changes
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# redrawn
|
| 13 |
+
|
| 14 |
+
**Does the map change the answer?** 1.9 million New York City crashes, one
|
| 15 |
+
question — do neighbourhoods where speeding is cited more often have more
|
| 16 |
+
injurious crashes? — and a different answer from every map of the city.
|
| 17 |
+
|
| 18 |
+
Between individual crashes the correlation is **+0.055**. Across census tracts
|
| 19 |
+
it is +0.262, across police precincts +0.469, across community districts +0.551,
|
| 20 |
+
across boroughs **+0.709**. Contiguous equal-size districts drawn deliberately
|
| 21 |
+
span **+0.07 to +0.83**. Not one crash record changes; only the boundaries move.
|
| 22 |
+
|
| 23 |
+
This page re-runs the analysis in your browser. It holds the per-tract crash
|
| 24 |
+
counts and the tract adjacency graph and recomputes the zone rates and the
|
| 25 |
+
correlation on every interaction, so nothing you see is a stored answer — and
|
| 26 |
+
the "draw a new map" button really does build a fresh contiguous partition of
|
| 27 |
+
2,310 census tracts.
|
| 28 |
+
|
| 29 |
+
Data: [NYPD Motor Vehicle Collisions](https://data.cityofnewyork.us/Public-Safety/Motor-Vehicle-Collisions-Crashes/h9gi-nx95)
|
| 30 |
+
and Department of City Planning boundaries, via NYC Open Data.
|
| 31 |
+
|
| 32 |
+
Method, controls and the full write-up: <https://github.com/UsmarHaider/redrawn>
|
data.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
index.html
CHANGED
|
@@ -1,19 +1,477 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>redrawn — does the map change the answer?</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--surface: #fcfcfb;
|
| 10 |
+
--panel: #ffffff;
|
| 11 |
+
--ink: #14140f;
|
| 12 |
+
--ink2: #52514e;
|
| 13 |
+
--muted: #898781;
|
| 14 |
+
--line: #e4e3dc;
|
| 15 |
+
--blue: #2a78d6;
|
| 16 |
+
--orange: #eb6834;
|
| 17 |
+
--aqua: #1baf7a;
|
| 18 |
+
--shadow: 0 1px 2px rgba(20,20,15,.05), 0 8px 24px rgba(20,20,15,.06);
|
| 19 |
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
| 20 |
+
}
|
| 21 |
+
@media (prefers-color-scheme: dark) {
|
| 22 |
+
:root {
|
| 23 |
+
--surface: #14140f; --panel: #1c1c17; --ink: #f3f2ec; --ink2: #b8b6ad;
|
| 24 |
+
--muted: #8a887f; --line: #2e2e26;
|
| 25 |
+
--blue: #6ea8f0; --orange: #ff8a5c; --aqua: #3fd39c;
|
| 26 |
+
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
* { box-sizing: border-box; }
|
| 30 |
+
body {
|
| 31 |
+
margin: 0; background: var(--surface); color: var(--ink);
|
| 32 |
+
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
| 33 |
+
-webkit-font-smoothing: antialiased;
|
| 34 |
+
}
|
| 35 |
+
.wrap { max-width: 1240px; margin: 0 auto; padding: 34px 22px 70px; }
|
| 36 |
+
|
| 37 |
+
header { margin-bottom: 26px; }
|
| 38 |
+
h1 {
|
| 39 |
+
margin: 0 0 6px; font-size: 30px; letter-spacing: -.022em; font-weight: 660;
|
| 40 |
+
}
|
| 41 |
+
h1 span { color: var(--muted); font-weight: 400; }
|
| 42 |
+
.lede { margin: 0; max-width: 74ch; color: var(--ink2); font-size: 15.5px; }
|
| 43 |
+
.lede b { color: var(--ink); font-weight: 620; }
|
| 44 |
+
|
| 45 |
+
.layout { display: grid; grid-template-columns: minmax(0,1.32fr) minmax(0,1fr); gap: 20px; }
|
| 46 |
+
@media (max-width: 940px) { .layout { grid-template-columns: 1fr; } }
|
| 47 |
+
|
| 48 |
+
.card {
|
| 49 |
+
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
| 50 |
+
padding: 16px 18px; box-shadow: var(--shadow);
|
| 51 |
+
}
|
| 52 |
+
.card h2 {
|
| 53 |
+
margin: 0 0 12px; font-size: 11.5px; font-weight: 660; letter-spacing: .075em;
|
| 54 |
+
text-transform: uppercase; color: var(--muted);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.maps { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
| 58 |
+
button.chip {
|
| 59 |
+
font: inherit; font-size: 13px; padding: 6px 11px; border-radius: 999px; cursor: pointer;
|
| 60 |
+
border: 1px solid var(--line); background: transparent; color: var(--ink2);
|
| 61 |
+
transition: background .12s, color .12s, border-color .12s;
|
| 62 |
+
}
|
| 63 |
+
button.chip:hover { border-color: var(--blue); color: var(--ink); }
|
| 64 |
+
button.chip[aria-pressed="true"] {
|
| 65 |
+
background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 560;
|
| 66 |
+
}
|
| 67 |
+
button.chip .k { opacity: .62; font-variant-numeric: tabular-nums; margin-left: 4px; }
|
| 68 |
+
|
| 69 |
+
#map { width: 100%; height: auto; display: block; }
|
| 70 |
+
#map path { vector-effect: none; }
|
| 71 |
+
#map path.hi { stroke: var(--ink) !important; }
|
| 72 |
+
|
| 73 |
+
.readout { display: flex; align-items: baseline; gap: 12px; margin-bottom: 2px; }
|
| 74 |
+
.rval {
|
| 75 |
+
font: 640 44px/1 var(--mono); letter-spacing: -.02em; font-variant-numeric: tabular-nums;
|
| 76 |
+
}
|
| 77 |
+
.rlab { color: var(--muted); font-size: 13px; }
|
| 78 |
+
.sub { color: var(--ink2); font-size: 13px; margin: 4px 0 0; }
|
| 79 |
+
|
| 80 |
+
.dial { margin: 16px 0 6px; }
|
| 81 |
+
.dial svg { width: 100%; height: 62px; display: block; }
|
| 82 |
+
|
| 83 |
+
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; margin-top: 4px; }
|
| 84 |
+
.stat { border-top: 1px solid var(--line); padding-top: 8px; }
|
| 85 |
+
.stat .v { font: 620 19px/1.2 var(--mono); font-variant-numeric: tabular-nums; }
|
| 86 |
+
.stat .l { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
| 87 |
+
|
| 88 |
+
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
| 89 |
+
button.solid {
|
| 90 |
+
font: inherit; font-size: 13.5px; font-weight: 560; padding: 8px 14px; border-radius: 8px;
|
| 91 |
+
cursor: pointer; border: 1px solid var(--blue); background: var(--blue); color: #fff;
|
| 92 |
+
}
|
| 93 |
+
button.solid:hover { filter: brightness(1.07); }
|
| 94 |
+
button.ghost {
|
| 95 |
+
font: inherit; font-size: 13px; padding: 8px 12px; border-radius: 8px; cursor: pointer;
|
| 96 |
+
border: 1px solid var(--line); background: transparent; color: var(--ink2);
|
| 97 |
+
}
|
| 98 |
+
button.ghost:hover { border-color: var(--muted); color: var(--ink); }
|
| 99 |
+
label.slider { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink2); }
|
| 100 |
+
input[type=range] { accent-color: var(--blue); width: 128px; }
|
| 101 |
+
select {
|
| 102 |
+
font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 7px;
|
| 103 |
+
border: 1px solid var(--line); background: var(--panel); color: var(--ink);
|
| 104 |
+
}
|
| 105 |
+
.kval { font: 600 13px var(--mono); font-variant-numeric: tabular-nums; min-width: 30px; }
|
| 106 |
+
|
| 107 |
+
#strip { width: 100%; height: 96px; display: block; margin-top: 4px; }
|
| 108 |
+
#scatter { width: 100%; height: 250px; display: block; }
|
| 109 |
+
|
| 110 |
+
.foot { margin-top: 26px; color: var(--muted); font-size: 12.5px; max-width: 88ch; }
|
| 111 |
+
.foot a { color: var(--blue); }
|
| 112 |
+
.tag {
|
| 113 |
+
display: inline-block; font-size: 11px; padding: 2px 7px; border-radius: 999px;
|
| 114 |
+
border: 1px solid var(--line); color: var(--muted); margin-left: 6px;
|
| 115 |
+
}
|
| 116 |
+
.tag.ok { color: var(--aqua); border-color: color-mix(in srgb, var(--aqua) 40%, transparent); }
|
| 117 |
+
#loading { padding: 40px 0; color: var(--muted); }
|
| 118 |
+
</style>
|
| 119 |
+
</head>
|
| 120 |
+
<body>
|
| 121 |
+
<div class="wrap">
|
| 122 |
+
<header>
|
| 123 |
+
<h1>redrawn <span>— does the map change the answer?</span></h1>
|
| 124 |
+
<p class="lede">
|
| 125 |
+
Every number below comes from the same <b id="ncrash">…</b> New York City crashes.
|
| 126 |
+
Nothing is added, removed or reweighted. The only thing that changes is
|
| 127 |
+
<b>where the boundaries are drawn</b> — and the correlation an analyst would
|
| 128 |
+
report moves from near zero to almost one.
|
| 129 |
+
</p>
|
| 130 |
+
</header>
|
| 131 |
+
|
| 132 |
+
<div id="loading">Loading the city…</div>
|
| 133 |
+
|
| 134 |
+
<div class="layout" id="app" hidden>
|
| 135 |
+
<div>
|
| 136 |
+
<div class="card">
|
| 137 |
+
<h2>The map</h2>
|
| 138 |
+
<div class="maps" id="maps"></div>
|
| 139 |
+
<svg id="map" preserveAspectRatio="xMidYMid meet" role="img"
|
| 140 |
+
aria-label="Choropleth of New York City coloured by zone injury rate"></svg>
|
| 141 |
+
<p class="sub" id="maphint">Shaded by each zone's injury rate. Hover a zone for its numbers.</p>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div>
|
| 147 |
+
<div class="card">
|
| 148 |
+
<h2>What this map reports</h2>
|
| 149 |
+
<div class="readout">
|
| 150 |
+
<span class="rval" id="r">—</span>
|
| 151 |
+
<span class="rlab" id="rlab">correlation across zones</span>
|
| 152 |
+
</div>
|
| 153 |
+
<p class="sub" id="rdesc"></p>
|
| 154 |
+
<div class="dial"><svg id="dial" viewBox="0 0 100 22" preserveAspectRatio="none"></svg></div>
|
| 155 |
+
<div class="grid2">
|
| 156 |
+
<div class="stat"><div class="v" id="kzones">—</div><div class="l">zones</div></div>
|
| 157 |
+
<div class="stat"><div class="v" id="rw">—</div><div class="l">weighted by crashes</div></div>
|
| 158 |
+
<div class="stat"><div class="v" id="pointr">—</div><div class="l">between individual crashes</div></div>
|
| 159 |
+
<div class="stat"><div class="v" id="contig">—</div><div class="l">every zone connected</div></div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div class="card" style="margin-top:16px">
|
| 164 |
+
<h2>Zone rates <span id="pairlab" class="tag"></span></h2>
|
| 165 |
+
<div class="controls" style="margin-bottom:10px">
|
| 166 |
+
<select id="pair"></select>
|
| 167 |
+
</div>
|
| 168 |
+
<svg id="scatter" role="img" aria-label="Scatter of zone-level rates"></svg>
|
| 169 |
+
<p class="sub">One dot per zone, sized by crash count.</p>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<div class="card" style="margin-top:16px">
|
| 173 |
+
<h2>Draw your own</h2>
|
| 174 |
+
<div class="controls">
|
| 175 |
+
<button class="solid" id="draw">Draw a new map</button>
|
| 176 |
+
<label class="slider">zones
|
| 177 |
+
<input type="range" id="k" min="5" max="400" value="71">
|
| 178 |
+
<span class="kval" id="kval">71</span>
|
| 179 |
+
</label>
|
| 180 |
+
<select id="family">
|
| 181 |
+
<option value="merge">balanced (merge)</option>
|
| 182 |
+
<option value="grow">ragged (region grow)</option>
|
| 183 |
+
</select>
|
| 184 |
+
<button class="ghost" id="clear">Clear</button>
|
| 185 |
+
</div>
|
| 186 |
+
<svg id="strip" role="img" aria-label="Correlations from every map drawn so far"></svg>
|
| 187 |
+
<p class="sub" id="striphint">
|
| 188 |
+
Each tick is one map you have drawn. Keep pressing — the answer wanders.
|
| 189 |
+
</p>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<p class="foot">
|
| 195 |
+
Crashes: NYPD Motor Vehicle Collisions, <span id="years">…</span>, via NYC Open Data.
|
| 196 |
+
Boundaries: NYC Department of City Planning. Correlations are recomputed in your
|
| 197 |
+
browser from the same per-tract counts the analysis uses — see
|
| 198 |
+
<span class="mono">maup.js</span>.
|
| 199 |
+
</p>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<script src="maup.js"></script>
|
| 203 |
+
<script>
|
| 204 |
+
(function () {
|
| 205 |
+
"use strict";
|
| 206 |
+
var D = null, paths = [], nodes = [], current = null, currentKey = "cdta";
|
| 207 |
+
var drawn = [], pairKey = "speeding", seedCounter = 1;
|
| 208 |
+
|
| 209 |
+
var $ = function (id) { return document.getElementById(id); };
|
| 210 |
+
var fmt = function (v, d) { return (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(d === undefined ? 3 : d); };
|
| 211 |
+
|
| 212 |
+
/* Sequential ramp matching the README figures. */
|
| 213 |
+
var RAMP = [[238,243,250],[157,192,232],[42,120,214],[18,53,94]];
|
| 214 |
+
function ramp(t) {
|
| 215 |
+
t = Math.max(0, Math.min(1, t)) * (RAMP.length - 1);
|
| 216 |
+
var i = Math.min(RAMP.length - 2, Math.floor(t)), f = t - i;
|
| 217 |
+
var a = RAMP[i], b = RAMP[i + 1];
|
| 218 |
+
return "rgb(" + Math.round(a[0] + (b[0] - a[0]) * f) + "," +
|
| 219 |
+
Math.round(a[1] + (b[1] - a[1]) * f) + "," +
|
| 220 |
+
Math.round(a[2] + (b[2] - a[2]) * f) + ")";
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function counts() {
|
| 224 |
+
var p = D.meta.pairs[pairKey];
|
| 225 |
+
return { x: D.counts[p.x], y: D.counts[p.y], xn: p.x, yn: p.y };
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function buildMap() {
|
| 229 |
+
var svg = $("map");
|
| 230 |
+
svg.setAttribute("viewBox", D.meta.viewBox.join(" "));
|
| 231 |
+
var frag = document.createDocumentFragment();
|
| 232 |
+
for (var i = 0; i < D.paths.length; i++) {
|
| 233 |
+
var el = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
| 234 |
+
el.setAttribute("d", D.paths[i]);
|
| 235 |
+
el.dataset.i = i;
|
| 236 |
+
frag.appendChild(el);
|
| 237 |
+
nodes.push(el);
|
| 238 |
+
}
|
| 239 |
+
svg.appendChild(frag);
|
| 240 |
+
// Stroke width is in user units; the viewBox is ~0.47 wide, so keep it tiny.
|
| 241 |
+
var scale = D.meta.viewBox[2] / 900;
|
| 242 |
+
var style = document.createElementNS("http://www.w3.org/2000/svg", "style");
|
| 243 |
+
style.textContent = "#map path{stroke-width:" + (scale * 0.35) + "}" +
|
| 244 |
+
"#map path.hi{stroke-width:" + (scale * 2.2) + "}";
|
| 245 |
+
svg.appendChild(style);
|
| 246 |
+
|
| 247 |
+
svg.addEventListener("mousemove", function (e) {
|
| 248 |
+
var t = e.target;
|
| 249 |
+
if (t.tagName !== "path") return;
|
| 250 |
+
hover(+t.dataset.i);
|
| 251 |
+
});
|
| 252 |
+
svg.addEventListener("mouseleave", function () {
|
| 253 |
+
$("maphint").textContent = "Shaded by each zone's injury rate. Hover a zone for its numbers.";
|
| 254 |
+
nodes.forEach(function (nd) { nd.classList.remove("hi"); });
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function hover(i) {
|
| 259 |
+
var z = current[i], c = counts();
|
| 260 |
+
var zn = 0, zx = 0, zy = 0, members = 0;
|
| 261 |
+
for (var j = 0; j < current.length; j++) {
|
| 262 |
+
if (current[j] === z) { zn += D.n[j]; zx += c.x[j]; zy += c.y[j]; members++; }
|
| 263 |
+
}
|
| 264 |
+
nodes.forEach(function (nd, j) { nd.classList.toggle("hi", current[j] === z); });
|
| 265 |
+
$("maphint").innerHTML = "<b>Zone " + z + "</b> · " + members + " tracts · " +
|
| 266 |
+
zn.toLocaleString() + " crashes · " + c.yn + " " + (100 * zy / zn).toFixed(1) + "% · " +
|
| 267 |
+
c.xn + " " + (100 * zx / zn).toFixed(2) + "%";
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
function paint() {
|
| 271 |
+
var c = counts();
|
| 272 |
+
var z = MAUP.zoneRates(current, D.n, c.x, c.y);
|
| 273 |
+
var lo = 0.15, hi = 0.40;
|
| 274 |
+
var k = 0;
|
| 275 |
+
for (var i = 0; i < current.length; i++) if (current[i] + 1 > k) k = current[i] + 1;
|
| 276 |
+
var zn = new Float64Array(k), zy = new Float64Array(k);
|
| 277 |
+
for (i = 0; i < current.length; i++) { zn[current[i]] += D.n[i]; zy[current[i]] += c.y[i]; }
|
| 278 |
+
var colour = new Array(k);
|
| 279 |
+
for (i = 0; i < k; i++) colour[i] = zn[i] > 0 ? ramp((zy[i] / zn[i] - lo) / (hi - lo)) : "transparent";
|
| 280 |
+
for (i = 0; i < nodes.length; i++) {
|
| 281 |
+
var col = colour[current[i]];
|
| 282 |
+
nodes[i].setAttribute("fill", col);
|
| 283 |
+
// Stroke each tract in its own fill colour. That closes the hairline
|
| 284 |
+
// seams between tracts without drawing a grid over the map, so tracts
|
| 285 |
+
// sharing a zone merge into one solid block and the zones are what the
|
| 286 |
+
// eye actually sees.
|
| 287 |
+
nodes[i].setAttribute("stroke", col);
|
| 288 |
+
}
|
| 289 |
+
return z;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function score(label) {
|
| 293 |
+
var c = counts();
|
| 294 |
+
var z = paint();
|
| 295 |
+
var r = MAUP.pearson(z.x, z.y);
|
| 296 |
+
var rw = MAUP.weightedPearson(z.x, z.y, z.n);
|
| 297 |
+
$("r").textContent = fmt(r);
|
| 298 |
+
$("r").style.color = r >= 0 ? "var(--blue)" : "var(--orange)";
|
| 299 |
+
$("kzones").textContent = z.k;
|
| 300 |
+
$("rw").textContent = fmt(rw);
|
| 301 |
+
$("pointr").textContent = fmt(D.meta.pointR[pairKey]);
|
| 302 |
+
var ok = MAUP.isContiguous(current, D.adjStart, D.adj);
|
| 303 |
+
$("contig").textContent = ok ? "yes" : "no";
|
| 304 |
+
$("rdesc").textContent = label;
|
| 305 |
+
dial(r);
|
| 306 |
+
scatter(z);
|
| 307 |
+
return r;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
function dial(r) {
|
| 311 |
+
var svg = $("dial"), p = D.meta.pointR[pairKey];
|
| 312 |
+
var toX = function (v) { return (v + 1) / 2 * 100; };
|
| 313 |
+
svg.innerHTML =
|
| 314 |
+
'<rect x="0" y="8" width="100" height="6" rx="3" fill="var(--line)"/>' +
|
| 315 |
+
'<rect x="' + Math.min(toX(0), toX(r)) + '" y="8" width="' + Math.abs(toX(r) - toX(0)) +
|
| 316 |
+
'" height="6" rx="3" fill="' + (r >= 0 ? "var(--blue)" : "var(--orange)") + '"/>' +
|
| 317 |
+
'<line x1="' + toX(0) + '" y1="4" x2="' + toX(0) + '" y2="18" stroke="var(--muted)" stroke-width=".5"/>' +
|
| 318 |
+
'<line x1="' + toX(p) + '" y1="3" x2="' + toX(p) + '" y2="19" stroke="var(--aqua)" stroke-width=".9"/>' +
|
| 319 |
+
'<circle cx="' + toX(r) + '" cy="11" r="3.6" fill="var(--panel)" stroke="' +
|
| 320 |
+
(r >= 0 ? "var(--blue)" : "var(--orange)") + '" stroke-width="1.6"/>' +
|
| 321 |
+
'<text x="0" y="21.5" font-size="4" fill="var(--muted)">-1</text>' +
|
| 322 |
+
'<text x="97" y="21.5" font-size="4" fill="var(--muted)">+1</text>' +
|
| 323 |
+
'<text x="' + toX(p) + '" y="3.4" font-size="4" fill="var(--aqua)" text-anchor="middle">crash level</text>';
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function scatter(z) {
|
| 327 |
+
var svg = $("scatter"), W = svg.clientWidth || 380, H = 250;
|
| 328 |
+
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
| 329 |
+
var m = { l: 44, r: 10, t: 10, b: 32 };
|
| 330 |
+
var xs = z.x, ys = z.y;
|
| 331 |
+
var xmax = Math.max.apply(null, xs) * 1.06 || 1;
|
| 332 |
+
var ymin = Math.min.apply(null, ys) * 0.94, ymax = Math.max.apply(null, ys) * 1.04 || 1;
|
| 333 |
+
var nmax = Math.max.apply(null, z.n);
|
| 334 |
+
var X = function (v) { return m.l + v / xmax * (W - m.l - m.r); };
|
| 335 |
+
var Y = function (v) { return H - m.b - (v - ymin) / (ymax - ymin || 1) * (H - m.t - m.b); };
|
| 336 |
+
var c = counts();
|
| 337 |
+
var out = '<line x1="' + m.l + '" y1="' + (H - m.b) + '" x2="' + (W - m.r) + '" y2="' + (H - m.b) +
|
| 338 |
+
'" stroke="var(--line)"/><line x1="' + m.l + '" y1="' + m.t + '" x2="' + m.l + '" y2="' +
|
| 339 |
+
(H - m.b) + '" stroke="var(--line)"/>';
|
| 340 |
+
for (var i = 0; i < xs.length; i++) {
|
| 341 |
+
var rad = 2 + 5 * Math.sqrt(z.n[i] / nmax);
|
| 342 |
+
out += '<circle cx="' + X(xs[i]).toFixed(1) + '" cy="' + Y(ys[i]).toFixed(1) + '" r="' +
|
| 343 |
+
rad.toFixed(1) + '" fill="var(--blue)" fill-opacity=".42" stroke="var(--blue)" stroke-opacity=".7" stroke-width=".7"/>';
|
| 344 |
+
}
|
| 345 |
+
out += '<text x="' + ((W + m.l) / 2) + '" y="' + (H - 6) + '" font-size="11" fill="var(--muted)" text-anchor="middle">' +
|
| 346 |
+
c.xn + ' rate</text>';
|
| 347 |
+
out += '<text transform="translate(12,' + ((H - m.b + m.t) / 2) + ') rotate(-90)" font-size="11" fill="var(--muted)" text-anchor="middle">' +
|
| 348 |
+
c.yn + ' rate</text>';
|
| 349 |
+
out += '<text x="' + m.l + '" y="' + (H - m.b + 14) + '" font-size="10" fill="var(--muted)">0</text>';
|
| 350 |
+
out += '<text x="' + (W - m.r) + '" y="' + (H - m.b + 14) + '" font-size="10" fill="var(--muted)" text-anchor="end">' +
|
| 351 |
+
(100 * xmax).toFixed(1) + '%</text>';
|
| 352 |
+
out += '<text x="' + (m.l - 6) + '" y="' + (m.t + 8) + '" font-size="10" fill="var(--muted)" text-anchor="end">' +
|
| 353 |
+
(100 * ymax).toFixed(0) + '%</text>';
|
| 354 |
+
svg.innerHTML = out;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function strip() {
|
| 358 |
+
var svg = $("strip"), W = svg.clientWidth || 520, H = 96;
|
| 359 |
+
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
| 360 |
+
var m = 26;
|
| 361 |
+
var X = function (v) { return m + (v + 1) / 2 * (W - 2 * m); };
|
| 362 |
+
var out = '<line x1="' + m + '" y1="62" x2="' + (W - m) + '" y2="62" stroke="var(--line)" stroke-width="2"/>';
|
| 363 |
+
[-1, -0.5, 0, 0.5, 1].forEach(function (t) {
|
| 364 |
+
out += '<line x1="' + X(t) + '" y1="58" x2="' + X(t) + '" y2="66" stroke="var(--muted)" stroke-width=".8"/>';
|
| 365 |
+
out += '<text x="' + X(t) + '" y="79" font-size="10" fill="var(--muted)" text-anchor="middle">' +
|
| 366 |
+
(t > 0 ? "+" : "") + t + '</text>';
|
| 367 |
+
});
|
| 368 |
+
drawn.forEach(function (d) {
|
| 369 |
+
out += '<line x1="' + X(d.r) + '" y1="40" x2="' + X(d.r) + '" y2="60" stroke="var(--blue)" stroke-opacity=".5" stroke-width="1.4"/>';
|
| 370 |
+
});
|
| 371 |
+
Object.keys(D.partitions).forEach(function (key) {
|
| 372 |
+
var p = D.partitions[key];
|
| 373 |
+
if (!p.official) return;
|
| 374 |
+
var r = MAUP.partitionCorrelation(p.labels, D.n, counts().x, counts().y);
|
| 375 |
+
out += '<line x1="' + X(r) + '" y1="18" x2="' + X(r) + '" y2="62" stroke="var(--orange)" stroke-width="1.6"/>';
|
| 376 |
+
out += '<text x="' + X(r) + '" y="14" font-size="9.5" fill="var(--orange)" text-anchor="middle">' +
|
| 377 |
+
(p.k >= 1000 ? "tracts" : p.k) + '</text>';
|
| 378 |
+
});
|
| 379 |
+
var pr = D.meta.pointR[pairKey];
|
| 380 |
+
out += '<line x1="' + X(pr) + '" y1="30" x2="' + X(pr) + '" y2="70" stroke="var(--aqua)" stroke-width="2"/>';
|
| 381 |
+
svg.innerHTML = out;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function select(key) {
|
| 385 |
+
currentKey = key;
|
| 386 |
+
var p = D.partitions[key];
|
| 387 |
+
current = Int32Array.from(p.labels);
|
| 388 |
+
document.querySelectorAll("#maps .chip").forEach(function (b) {
|
| 389 |
+
b.setAttribute("aria-pressed", String(b.dataset.key === key));
|
| 390 |
+
});
|
| 391 |
+
score(p.official ? "An official map of New York City." : "A map generated by this study.");
|
| 392 |
+
strip();
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
function drawNew() {
|
| 396 |
+
var k = +$("k").value;
|
| 397 |
+
var rand = MAUP.mulberry32(0x9e37 + (seedCounter++) * 2654435761 % 2147483647);
|
| 398 |
+
var t0 = performance.now();
|
| 399 |
+
current = $("family").value === "merge"
|
| 400 |
+
? MAUP.mergeToK(D.adjStart, D.adj, D.n, k, rand)
|
| 401 |
+
: MAUP.growToK(D.adjStart, D.adj, k, rand);
|
| 402 |
+
document.querySelectorAll("#maps .chip").forEach(function (b) {
|
| 403 |
+
b.setAttribute("aria-pressed", "false");
|
| 404 |
+
});
|
| 405 |
+
var r = score("A map this page just invented — contiguous, " + k + " zones, " +
|
| 406 |
+
"drawn without looking at the crash data.");
|
| 407 |
+
drawn.push({ r: r, k: k });
|
| 408 |
+
$("striphint").innerHTML = drawn.length === 1
|
| 409 |
+
? "One map drawn. Keep pressing."
|
| 410 |
+
: drawn.length + " maps drawn · range " + fmt(Math.min.apply(null, drawn.map(function (d) { return d.r; }))) +
|
| 411 |
+
" to " + fmt(Math.max.apply(null, drawn.map(function (d) { return d.r; }))) +
|
| 412 |
+
" · " + (performance.now() - t0).toFixed(0) + " ms to build the last one";
|
| 413 |
+
strip();
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
fetch("data.json").then(function (res) { return res.json(); }).then(function (data) {
|
| 417 |
+
D = data;
|
| 418 |
+
D.n = Float64Array.from(D.n);
|
| 419 |
+
Object.keys(D.counts).forEach(function (key) { D.counts[key] = Float64Array.from(D.counts[key]); });
|
| 420 |
+
D.adjStart = Int32Array.from(D.adjStart);
|
| 421 |
+
D.adj = Int32Array.from(D.adj);
|
| 422 |
+
|
| 423 |
+
$("ncrash").textContent = D.meta.crashes.toLocaleString();
|
| 424 |
+
$("years").textContent = D.meta.years[0] + "–" + D.meta.years[1];
|
| 425 |
+
|
| 426 |
+
var maps = $("maps");
|
| 427 |
+
Object.keys(D.partitions).forEach(function (key) {
|
| 428 |
+
var p = D.partitions[key];
|
| 429 |
+
var b = document.createElement("button");
|
| 430 |
+
b.className = "chip";
|
| 431 |
+
b.dataset.key = key;
|
| 432 |
+
b.innerHTML = p.name.replace(/ \(K=\d+\)/, "") + '<span class="k">' + p.k + "</span>";
|
| 433 |
+
b.setAttribute("aria-pressed", "false");
|
| 434 |
+
b.onclick = function () { select(key); };
|
| 435 |
+
maps.appendChild(b);
|
| 436 |
+
});
|
| 437 |
+
|
| 438 |
+
var sel = $("pair");
|
| 439 |
+
Object.keys(D.meta.pairs).forEach(function (key) {
|
| 440 |
+
var o = document.createElement("option");
|
| 441 |
+
o.value = key;
|
| 442 |
+
o.textContent = D.meta.pairs[key].x + " vs " + D.meta.pairs[key].y;
|
| 443 |
+
sel.appendChild(o);
|
| 444 |
+
});
|
| 445 |
+
sel.value = pairKey;
|
| 446 |
+
sel.onchange = function () {
|
| 447 |
+
pairKey = sel.value;
|
| 448 |
+
$("pairlab").textContent = D.meta.pairs[pairKey].x;
|
| 449 |
+
drawn = [];
|
| 450 |
+
score($("rdesc").textContent);
|
| 451 |
+
strip();
|
| 452 |
+
};
|
| 453 |
+
$("pairlab").textContent = D.meta.pairs[pairKey].x;
|
| 454 |
+
|
| 455 |
+
$("k").oninput = function () { $("kval").textContent = $("k").value; };
|
| 456 |
+
$("draw").onclick = drawNew;
|
| 457 |
+
$("clear").onclick = function () {
|
| 458 |
+
drawn = [];
|
| 459 |
+
$("striphint").textContent = "Each tick is one map you have drawn. Keep pressing — the answer wanders.";
|
| 460 |
+
strip();
|
| 461 |
+
};
|
| 462 |
+
|
| 463 |
+
$("loading").hidden = true;
|
| 464 |
+
$("app").hidden = false;
|
| 465 |
+
buildMap();
|
| 466 |
+
// ?map=<key> opens on a chosen map, which is how the README screenshots
|
| 467 |
+
// land on the official districts and the gerrymandered one.
|
| 468 |
+
var wanted = new URLSearchParams(location.search).get("map");
|
| 469 |
+
select(D.partitions[wanted] ? wanted : "cdta");
|
| 470 |
+
window.addEventListener("resize", function () { score($("rdesc").textContent); strip(); });
|
| 471 |
+
}).catch(function (err) {
|
| 472 |
+
$("loading").textContent = "Could not load data.json — run `make web` to export it. (" + err + ")";
|
| 473 |
+
});
|
| 474 |
+
})();
|
| 475 |
+
</script>
|
| 476 |
+
</body>
|
| 477 |
</html>
|
maup.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* maup.js -- the analysis, ported to run in the browser.
|
| 3 |
+
*
|
| 4 |
+
* The page does not display precomputed answers. It holds the same per-tract
|
| 5 |
+
* crash counts the Python pipeline works from, and recomputes zone totals,
|
| 6 |
+
* rates and the correlation every time the reader changes the map. These
|
| 7 |
+
* functions are the port, and `tests/test_js_parity.py` runs this same file
|
| 8 |
+
* under Node against the Python results so the two cannot drift apart.
|
| 9 |
+
*
|
| 10 |
+
* Loads as a plain script in the browser (window.MAUP) and via require() in Node.
|
| 11 |
+
*/
|
| 12 |
+
(function (root, factory) {
|
| 13 |
+
if (typeof module === "object" && module.exports) module.exports = factory();
|
| 14 |
+
else root.MAUP = factory();
|
| 15 |
+
})(typeof self !== "undefined" ? self : this, function () {
|
| 16 |
+
"use strict";
|
| 17 |
+
|
| 18 |
+
/** Deterministic 32-bit PRNG, so a shared seed reproduces a map exactly. */
|
| 19 |
+
function mulberry32(seed) {
|
| 20 |
+
let a = seed >>> 0;
|
| 21 |
+
return function () {
|
| 22 |
+
a = (a + 0x6d2b79f5) >>> 0;
|
| 23 |
+
let t = a;
|
| 24 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 25 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 26 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 27 |
+
};
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function pearson(x, y) {
|
| 31 |
+
const n = x.length;
|
| 32 |
+
if (n < 3) return NaN;
|
| 33 |
+
let mx = 0, my = 0;
|
| 34 |
+
for (let i = 0; i < n; i++) { mx += x[i]; my += y[i]; }
|
| 35 |
+
mx /= n; my /= n;
|
| 36 |
+
let sxy = 0, sxx = 0, syy = 0;
|
| 37 |
+
for (let i = 0; i < n; i++) {
|
| 38 |
+
const dx = x[i] - mx, dy = y[i] - my;
|
| 39 |
+
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
|
| 40 |
+
}
|
| 41 |
+
const denom = Math.sqrt(sxx * syy);
|
| 42 |
+
return denom > 0 ? sxy / denom : NaN;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function weightedPearson(x, y, w) {
|
| 46 |
+
const n = x.length;
|
| 47 |
+
let total = 0;
|
| 48 |
+
for (let i = 0; i < n; i++) total += w[i];
|
| 49 |
+
if (total <= 0 || n < 3) return NaN;
|
| 50 |
+
let mx = 0, my = 0;
|
| 51 |
+
for (let i = 0; i < n; i++) { mx += w[i] * x[i]; my += w[i] * y[i]; }
|
| 52 |
+
mx /= total; my /= total;
|
| 53 |
+
let cov = 0, vx = 0, vy = 0;
|
| 54 |
+
for (let i = 0; i < n; i++) {
|
| 55 |
+
const dx = x[i] - mx, dy = y[i] - my;
|
| 56 |
+
cov += w[i] * dx * dy; vx += w[i] * dx * dx; vy += w[i] * dy * dy;
|
| 57 |
+
}
|
| 58 |
+
if (vx <= 0 || vy <= 0) return NaN;
|
| 59 |
+
return cov / total / Math.sqrt((vx / total) * (vy / total));
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** Per-zone crash count and the two indicator rates; empty zones dropped. */
|
| 63 |
+
function zoneRates(labels, n, x, y) {
|
| 64 |
+
let k = 0;
|
| 65 |
+
for (let i = 0; i < labels.length; i++) if (labels[i] + 1 > k) k = labels[i] + 1;
|
| 66 |
+
const zn = new Float64Array(k), zx = new Float64Array(k), zy = new Float64Array(k);
|
| 67 |
+
for (let i = 0; i < labels.length; i++) {
|
| 68 |
+
const z = labels[i];
|
| 69 |
+
zn[z] += n[i]; zx[z] += x[i]; zy[z] += y[i];
|
| 70 |
+
}
|
| 71 |
+
const outN = [], outX = [], outY = [];
|
| 72 |
+
for (let z = 0; z < k; z++) {
|
| 73 |
+
if (zn[z] > 0) { outN.push(zn[z]); outX.push(zx[z] / zn[z]); outY.push(zy[z] / zn[z]); }
|
| 74 |
+
}
|
| 75 |
+
return { n: outN, x: outX, y: outY, k: outN.length };
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function partitionCorrelation(labels, n, x, y, weighted) {
|
| 79 |
+
const z = zoneRates(labels, n, x, y);
|
| 80 |
+
return weighted ? weightedPearson(z.x, z.y, z.n) : pearson(z.x, z.y);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* ----------------------------------------------------------------------- *
|
| 84 |
+
* Map generation. Both families mirror src/redrawn/partitions.py; the
|
| 85 |
+
* random draws differ from Python's (different PRNG), so the maps are not
|
| 86 |
+
* identical, but they come from the same procedure and the same constraints.
|
| 87 |
+
* ----------------------------------------------------------------------- */
|
| 88 |
+
|
| 89 |
+
/** Minimal binary heap keyed by a numeric score. */
|
| 90 |
+
function Heap() {
|
| 91 |
+
this.items = [];
|
| 92 |
+
}
|
| 93 |
+
Heap.prototype.push = function (score, value) {
|
| 94 |
+
const a = this.items;
|
| 95 |
+
a.push([score, value]);
|
| 96 |
+
let i = a.length - 1;
|
| 97 |
+
while (i > 0) {
|
| 98 |
+
const p = (i - 1) >> 1;
|
| 99 |
+
if (a[p][0] <= a[i][0]) break;
|
| 100 |
+
const t = a[p]; a[p] = a[i]; a[i] = t;
|
| 101 |
+
i = p;
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
Heap.prototype.pop = function () {
|
| 105 |
+
const a = this.items;
|
| 106 |
+
if (!a.length) return null;
|
| 107 |
+
const top = a[0], last = a.pop();
|
| 108 |
+
if (a.length) {
|
| 109 |
+
a[0] = last;
|
| 110 |
+
let i = 0;
|
| 111 |
+
for (;;) {
|
| 112 |
+
const l = 2 * i + 1, r = l + 1;
|
| 113 |
+
let s = i;
|
| 114 |
+
if (l < a.length && a[l][0] < a[s][0]) s = l;
|
| 115 |
+
if (r < a.length && a[r][0] < a[s][0]) s = r;
|
| 116 |
+
if (s === i) break;
|
| 117 |
+
const t = a[s]; a[s] = a[i]; a[i] = t;
|
| 118 |
+
i = s;
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
return top;
|
| 122 |
+
};
|
| 123 |
+
Heap.prototype.size = function () { return this.items.length; };
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* Agglomerate the lightest adjacent pair of zones until K remain -- the same
|
| 127 |
+
* balanced merge the Python pipeline uses to build neutral maps.
|
| 128 |
+
*/
|
| 129 |
+
function mergeToK(adjStart, adj, weight, k, rand, jitter) {
|
| 130 |
+
jitter = jitter === undefined ? 0.35 : jitter;
|
| 131 |
+
const n = weight.length;
|
| 132 |
+
const parent = new Int32Array(n);
|
| 133 |
+
const zoneWeight = new Float64Array(n);
|
| 134 |
+
const alive = new Uint8Array(n);
|
| 135 |
+
const version = new Int32Array(n);
|
| 136 |
+
const nbrs = [];
|
| 137 |
+
for (let i = 0; i < n; i++) {
|
| 138 |
+
parent[i] = i; zoneWeight[i] = weight[i]; alive[i] = 1;
|
| 139 |
+
const s = new Set();
|
| 140 |
+
for (let p = adjStart[i]; p < adjStart[i + 1]; p++) s.add(adj[p]);
|
| 141 |
+
nbrs.push(s);
|
| 142 |
+
}
|
| 143 |
+
function find(i) {
|
| 144 |
+
while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; }
|
| 145 |
+
return i;
|
| 146 |
+
}
|
| 147 |
+
const key = (a, b) => (zoneWeight[a] + zoneWeight[b]) * (1 + jitter * rand());
|
| 148 |
+
|
| 149 |
+
const heap = new Heap();
|
| 150 |
+
for (let a = 0; a < n; a++) {
|
| 151 |
+
nbrs[a].forEach(function (b) {
|
| 152 |
+
if (a < b) heap.push(key(a, b), [a, b, 0, 0]);
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
let live = n;
|
| 157 |
+
while (live > k) {
|
| 158 |
+
let a, b;
|
| 159 |
+
if (!heap.size()) {
|
| 160 |
+
const rest = [];
|
| 161 |
+
for (let i = 0; i < n; i++) if (alive[i]) rest.push(i);
|
| 162 |
+
if (rest.length < 2) break;
|
| 163 |
+
rest.sort((p, q) => zoneWeight[p] - zoneWeight[q]);
|
| 164 |
+
a = rest[0]; b = rest[1];
|
| 165 |
+
} else {
|
| 166 |
+
const top = heap.pop();
|
| 167 |
+
a = top[1][0]; b = top[1][1];
|
| 168 |
+
if (!alive[a] || !alive[b] || a === b) continue;
|
| 169 |
+
if (version[a] !== top[1][2] || version[b] !== top[1][3]) continue;
|
| 170 |
+
}
|
| 171 |
+
parent[b] = a;
|
| 172 |
+
alive[b] = 0;
|
| 173 |
+
zoneWeight[a] += zoneWeight[b];
|
| 174 |
+
const merged = new Set();
|
| 175 |
+
nbrs[a].forEach((v) => { const f = find(v); if (f !== a) merged.add(f); });
|
| 176 |
+
nbrs[b].forEach((v) => { const f = find(v); if (f !== a) merged.add(f); });
|
| 177 |
+
nbrs[a] = merged;
|
| 178 |
+
version[a]++;
|
| 179 |
+
live--;
|
| 180 |
+
merged.forEach(function (c) {
|
| 181 |
+
if (alive[c]) {
|
| 182 |
+
nbrs[c].add(a);
|
| 183 |
+
const lo = Math.min(a, c), hi = Math.max(a, c);
|
| 184 |
+
heap.push(key(a, c), [lo, hi, version[lo], version[hi]]);
|
| 185 |
+
}
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
return relabel(Array.from({ length: n }, (_, i) => find(i)));
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/** Multi-seed region growing: K seeds annex random unclaimed neighbours. */
|
| 192 |
+
function growToK(adjStart, adj, k, rand) {
|
| 193 |
+
const n = adjStart.length - 1;
|
| 194 |
+
const labels = new Int32Array(n).fill(-1);
|
| 195 |
+
const order = shuffled(n, rand);
|
| 196 |
+
const frontier = [];
|
| 197 |
+
for (let z = 0; z < k; z++) {
|
| 198 |
+
labels[order[z]] = z;
|
| 199 |
+
const f = [];
|
| 200 |
+
for (let p = adjStart[order[z]]; p < adjStart[order[z] + 1]; p++) {
|
| 201 |
+
if (labels[adj[p]] < 0) f.push(adj[p]);
|
| 202 |
+
}
|
| 203 |
+
frontier.push(f);
|
| 204 |
+
}
|
| 205 |
+
let remaining = n - k;
|
| 206 |
+
let active = [];
|
| 207 |
+
for (let z = 0; z < k; z++) if (frontier[z].length) active.push(z);
|
| 208 |
+
|
| 209 |
+
while (remaining > 0 && active.length) {
|
| 210 |
+
const pos = Math.floor(rand() * active.length);
|
| 211 |
+
const zone = active[pos];
|
| 212 |
+
const pool = frontier[zone];
|
| 213 |
+
let t = -1;
|
| 214 |
+
while (pool.length) {
|
| 215 |
+
const idx = Math.floor(rand() * pool.length);
|
| 216 |
+
const cand = pool[idx];
|
| 217 |
+
pool[idx] = pool[pool.length - 1];
|
| 218 |
+
pool.pop();
|
| 219 |
+
if (labels[cand] < 0) { t = cand; break; }
|
| 220 |
+
}
|
| 221 |
+
if (t < 0) { active.splice(pos, 1); continue; }
|
| 222 |
+
labels[t] = zone;
|
| 223 |
+
remaining--;
|
| 224 |
+
for (let p = adjStart[t]; p < adjStart[t + 1]; p++) {
|
| 225 |
+
if (labels[adj[p]] < 0) pool.push(adj[p]);
|
| 226 |
+
}
|
| 227 |
+
if (!pool.length) active.splice(pos, 1);
|
| 228 |
+
}
|
| 229 |
+
// Any pocket the growth could not reach joins an adjacent zone.
|
| 230 |
+
for (let i = 0; i < n; i++) {
|
| 231 |
+
if (labels[i] >= 0) continue;
|
| 232 |
+
for (let p = adjStart[i]; p < adjStart[i + 1]; p++) {
|
| 233 |
+
if (labels[adj[p]] >= 0) { labels[i] = labels[adj[p]]; break; }
|
| 234 |
+
}
|
| 235 |
+
if (labels[i] < 0) labels[i] = 0;
|
| 236 |
+
}
|
| 237 |
+
return relabel(Array.from(labels));
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function shuffled(n, rand) {
|
| 241 |
+
const a = Array.from({ length: n }, (_, i) => i);
|
| 242 |
+
for (let i = n - 1; i > 0; i--) {
|
| 243 |
+
const j = Math.floor(rand() * (i + 1));
|
| 244 |
+
const t = a[i]; a[i] = a[j]; a[j] = t;
|
| 245 |
+
}
|
| 246 |
+
return a;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function relabel(labels) {
|
| 250 |
+
const map = new Map();
|
| 251 |
+
const out = new Int32Array(labels.length);
|
| 252 |
+
for (let i = 0; i < labels.length; i++) {
|
| 253 |
+
if (!map.has(labels[i])) map.set(labels[i], map.size);
|
| 254 |
+
out[i] = map.get(labels[i]);
|
| 255 |
+
}
|
| 256 |
+
return out;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
/** Every zone one connected block? Used by the page's contiguity badge. */
|
| 260 |
+
function isContiguous(labels, adjStart, adj) {
|
| 261 |
+
const byZone = new Map();
|
| 262 |
+
for (let i = 0; i < labels.length; i++) {
|
| 263 |
+
if (!byZone.has(labels[i])) byZone.set(labels[i], []);
|
| 264 |
+
byZone.get(labels[i]).push(i);
|
| 265 |
+
}
|
| 266 |
+
for (const members of byZone.values()) {
|
| 267 |
+
const set = new Set(members);
|
| 268 |
+
const stack = [members[0]];
|
| 269 |
+
const seen = new Set(stack);
|
| 270 |
+
while (stack.length) {
|
| 271 |
+
const v = stack.pop();
|
| 272 |
+
for (let p = adjStart[v]; p < adjStart[v + 1]; p++) {
|
| 273 |
+
const w = adj[p];
|
| 274 |
+
if (set.has(w) && !seen.has(w)) { seen.add(w); stack.push(w); }
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
if (seen.size !== set.size) return false;
|
| 278 |
+
}
|
| 279 |
+
return true;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
return {
|
| 283 |
+
mulberry32, pearson, weightedPearson, zoneRates, partitionCorrelation,
|
| 284 |
+
mergeToK, growToK, isContiguous, relabel,
|
| 285 |
+
};
|
| 286 |
+
});
|