usmar commited on
Commit
6babaf2
·
verified ·
1 Parent(s): 8d6dab8

Publish the redrawn interface

Browse files
Files changed (4) hide show
  1. README.md +27 -5
  2. data.json +0 -0
  3. index.html +475 -17
  4. maup.js +286 -0
README.md CHANGED
@@ -1,10 +1,32 @@
1
  ---
2
- title: Redrawn
3
- emoji: 📊
4
- colorFrom: indigo
5
- colorTo: pink
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ });