@mailwoman/tiger 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/geoid.ts +3 -1
- package/out/geoid.d.ts.map +1 -1
- package/out/geoid.js +2 -1
- package/out/geoid.js.map +1 -1
- package/out/sdk/fetch.d.ts.map +1 -1
- package/out/sdk/fetch.js +18 -9
- package/out/sdk/fetch.js.map +1 -1
- package/out/sdk/redistricting.d.ts.map +1 -1
- package/out/sdk/redistricting.js +10 -5
- package/out/sdk/redistricting.js.map +1 -1
- package/out/tools/race-dots-map.d.ts +19 -0
- package/out/tools/race-dots-map.d.ts.map +1 -0
- package/out/tools/race-dots-map.js +147 -0
- package/out/tools/race-dots-map.js.map +1 -0
- package/out/tools/race-dots.d.ts +25 -0
- package/out/tools/race-dots.d.ts.map +1 -0
- package/out/tools/race-dots.js +137 -0
- package/out/tools/race-dots.js.map +1 -0
- package/out/tools/serve-range.d.ts +14 -0
- package/out/tools/serve-range.d.ts.map +1 -0
- package/out/tools/serve-range.js +57 -0
- package/out/tools/serve-range.js.map +1 -0
- package/out/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -3
- package/sdk/fetch.ts +18 -9
- package/sdk/redistricting.ts +11 -5
- package/tools/race-dots-map.ts +166 -0
- package/tools/race-dots.ts +171 -0
- package/tools/serve-range.ts +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/tiger",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"description": "US Census TIGER/Line data processing and analysis.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"census",
|
|
@@ -38,13 +38,17 @@
|
|
|
38
38
|
"test": "node ./out/sdk/TIGERService.test.js"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@mailwoman/core": "5.
|
|
42
|
-
"@mailwoman/spatial": "5.
|
|
41
|
+
"@mailwoman/core": "5.7.0",
|
|
42
|
+
"@mailwoman/spatial": "5.7.0",
|
|
43
43
|
"@turf/boolean-contains": "^7.3.5",
|
|
44
44
|
"kysely": "^0.29.2",
|
|
45
45
|
"shapefile-parser": "^1.0.3",
|
|
46
|
+
"spliterator": "^3.2.0",
|
|
46
47
|
"type-fest": "^5.7.0"
|
|
47
48
|
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@protomaps/basemaps": "^5.7.2"
|
|
51
|
+
},
|
|
48
52
|
"engines": {
|
|
49
53
|
"node": ">=24.18.0"
|
|
50
54
|
}
|
package/sdk/fetch.ts
CHANGED
|
@@ -28,13 +28,13 @@ import { spawn } from "node:child_process"
|
|
|
28
28
|
import { createWriteStream, existsSync } from "node:fs"
|
|
29
29
|
import { mkdir, rename } from "node:fs/promises"
|
|
30
30
|
import { dirname, join } from "node:path"
|
|
31
|
-
import { createInterface } from "node:readline"
|
|
32
31
|
import { DatabaseSync } from "node:sqlite"
|
|
33
32
|
import { Readable } from "node:stream"
|
|
34
33
|
import { pipeline } from "node:stream/promises"
|
|
35
34
|
|
|
36
35
|
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
37
36
|
import { mailwomanDataRoot } from "@mailwoman/core/utils"
|
|
37
|
+
import { TextSpliterator } from "spliterator"
|
|
38
38
|
|
|
39
39
|
import type { TIGERBlockTable, TIGERDatabase, TIGERPlaceTable, TIGERStreetTable } from "./schema.js"
|
|
40
40
|
import { initializeTIGERSchema, TIGER_PRAGMAS } from "./schema.js"
|
|
@@ -197,7 +197,9 @@ async function discoverCounties(state: string, vintage: number): Promise<string[
|
|
|
197
197
|
const re = new RegExp(`tl_${vintage}_${state}(\\d{3})_addrfeat\\.zip`, "g")
|
|
198
198
|
const counties = new Set<string>()
|
|
199
199
|
|
|
200
|
-
for (let m = re.exec(html); m; m = re.exec(html))
|
|
200
|
+
for (let m = re.exec(html); m; m = re.exec(html)) {
|
|
201
|
+
counties.add(m[1]!)
|
|
202
|
+
}
|
|
201
203
|
|
|
202
204
|
return [...counties].sort()
|
|
203
205
|
}
|
|
@@ -235,28 +237,33 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
|
|
|
235
237
|
await initializeTIGERSchema(kdb)
|
|
236
238
|
|
|
237
239
|
const insertBatch = async (rows: Row[]): Promise<void> => {
|
|
238
|
-
if (level === "tabblock20")
|
|
240
|
+
if (level === "tabblock20") {
|
|
239
241
|
await kdb
|
|
240
242
|
.insertInto("tabblock20")
|
|
241
243
|
.values(rows as TIGERBlockTable[])
|
|
242
244
|
.execute()
|
|
243
|
-
else if (level === "place")
|
|
245
|
+
} else if (level === "place") {
|
|
244
246
|
await kdb
|
|
245
247
|
.insertInto("tiger_places")
|
|
246
248
|
.values(rows as TIGERPlaceTable[])
|
|
247
249
|
.execute()
|
|
248
|
-
else
|
|
250
|
+
} else {
|
|
249
251
|
await kdb
|
|
250
252
|
.insertInto("tiger_streets")
|
|
251
253
|
.values(rows as TIGERStreetTable[])
|
|
252
254
|
.execute()
|
|
255
|
+
}
|
|
253
256
|
}
|
|
254
257
|
|
|
255
258
|
try {
|
|
256
259
|
// Idempotent re-run: drop this state's rows first (state_code for blocks, statefp otherwise).
|
|
257
|
-
if (level === "tabblock20")
|
|
258
|
-
|
|
259
|
-
else
|
|
260
|
+
if (level === "tabblock20") {
|
|
261
|
+
await kdb.deleteFrom("tabblock20").where("state_code", "=", state).execute()
|
|
262
|
+
} else if (level === "place") {
|
|
263
|
+
await kdb.deleteFrom("tiger_places").where("statefp", "=", state).execute()
|
|
264
|
+
} else {
|
|
265
|
+
await kdb.deleteFrom("tiger_streets").where("statefp", "=", state).execute()
|
|
266
|
+
}
|
|
260
267
|
|
|
261
268
|
let inserted = 0
|
|
262
269
|
let batch: Row[] = []
|
|
@@ -300,7 +307,9 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
|
|
|
300
307
|
child.stderr.on("data", (d) => (stderr += d))
|
|
301
308
|
const exited = new Promise<number>((resolve) => child.on("close", (code) => resolve(code ?? 0)))
|
|
302
309
|
|
|
303
|
-
|
|
310
|
+
// GeoJSONSeq is line-delimited; keep the per-line `JSON.parse` in a try/catch so a malformed
|
|
311
|
+
// record is tolerated (skipped), not thrown.
|
|
312
|
+
for await (const line of TextSpliterator.fromAsync(child.stdout)) {
|
|
304
313
|
if (!line) continue
|
|
305
314
|
let feat: { properties?: Record<string, unknown>; geometry?: unknown }
|
|
306
315
|
|
package/sdk/redistricting.ts
CHANGED
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process"
|
|
20
|
-
import {
|
|
20
|
+
import { createWriteStream, existsSync } from "node:fs"
|
|
21
21
|
import { mkdir, rename } from "node:fs/promises"
|
|
22
22
|
import { dirname, join } from "node:path"
|
|
23
|
-
import { createInterface } from "node:readline"
|
|
24
23
|
import { DatabaseSync } from "node:sqlite"
|
|
25
24
|
import { Readable } from "node:stream"
|
|
26
25
|
import { pipeline } from "node:stream/promises"
|
|
27
26
|
|
|
28
27
|
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
29
28
|
import { mailwomanDataRoot } from "@mailwoman/core/utils"
|
|
29
|
+
import { TextSpliterator } from "spliterator"
|
|
30
30
|
|
|
31
31
|
import { AdminLevel1CodeToAbbreviation, StateName, type AdminLevel1Code } from "../state.js"
|
|
32
32
|
import { initializeTIGERSchema, TIGER_PRAGMAS, type PLBlockTable, type TIGERDatabase } from "./schema.js"
|
|
@@ -118,9 +118,15 @@ async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
|
|
|
118
118
|
return false
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// Pipe-delimited, fixed field-offset census rows — the manual `split("|")` at each call site stays (the
|
|
122
|
+
// parse indexes by position, not by header). spliterator keeps CRLF's trailing CR where readline stripped
|
|
123
|
+
// it, but every field this parser reads (geo GEOCODE/LOGRECNO ≤ 9, segment-1 LOGRECNO + P2 ≤ 86) sits well
|
|
124
|
+
// before the final column, so the retained CR only ever lands on an unread trailing field.
|
|
121
125
|
async function eachLine(path: string, fn: (line: string) => void): Promise<void> {
|
|
122
|
-
for await (const line of
|
|
123
|
-
if (line)
|
|
126
|
+
for await (const line of TextSpliterator.fromAsync(path)) {
|
|
127
|
+
if (line) {
|
|
128
|
+
fn(line)
|
|
129
|
+
}
|
|
124
130
|
}
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -198,7 +204,7 @@ export async function* fetchRedistricting(
|
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
// Pass 2: segment 1 → P2 counts for the mapped LOGRECNOs, flushing as we go.
|
|
201
|
-
for await (const line of
|
|
207
|
+
for await (const line of TextSpliterator.fromAsync(seg1Path)) {
|
|
202
208
|
if (!line) continue
|
|
203
209
|
const f = line.split("|")
|
|
204
210
|
const geoid = logToGeoid.get(f[SEG_LOGRECNO] ?? "")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Render a race-by-dot-density PMTiles tileset (from `race-dots.ts` → tippecanoe) as a standalone
|
|
7
|
+
* MapLibre page on the house stack: the Protomaps `basemap-v4` vector basemap (layers generated +
|
|
8
|
+
* inlined here, à la `registry/map-html.ts`) under a circle layer of the dots, colored by race
|
|
9
|
+
* category. The dots PMTiles is read client-side via the `pmtiles` protocol.
|
|
10
|
+
*
|
|
11
|
+
* Each dot is one of `--per` people of a category, placed at random inside its Census block — a
|
|
12
|
+
* representation, not a record about any address. Serve over localhost (the house tile server
|
|
13
|
+
* CORS-restricts to localhost + the docs domains).
|
|
14
|
+
*
|
|
15
|
+
* Run: node --experimental-strip-types scripts/census/race-dots-map.ts\
|
|
16
|
+
* --pmtiles-url http://localhost:8899/race-dots-oc.pmtiles --out /tmp/race-dots-oc.html
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { writeFileSync } from "node:fs"
|
|
20
|
+
import { parseArgs } from "node:util"
|
|
21
|
+
|
|
22
|
+
import { layers, namedFlavor } from "@protomaps/basemaps"
|
|
23
|
+
|
|
24
|
+
// Loose scan parity with the retired local argv helpers: unknown flags tolerated.
|
|
25
|
+
const { values: rawValues } = parseArgs({
|
|
26
|
+
options: {
|
|
27
|
+
lat: { type: "string" },
|
|
28
|
+
lng: { type: "string" },
|
|
29
|
+
out: { type: "string" },
|
|
30
|
+
per: { type: "string" },
|
|
31
|
+
"pmtiles-url": { type: "string" },
|
|
32
|
+
title: { type: "string" },
|
|
33
|
+
zoom: { type: "string" },
|
|
34
|
+
},
|
|
35
|
+
strict: false,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
})
|
|
38
|
+
// Typed view: strict:false loosens TS inference, but declared options always parse to their schema type.
|
|
39
|
+
const values = rawValues as {
|
|
40
|
+
lat?: string
|
|
41
|
+
lng?: string
|
|
42
|
+
out?: string
|
|
43
|
+
per?: string
|
|
44
|
+
"pmtiles-url"?: string
|
|
45
|
+
title?: string
|
|
46
|
+
zoom?: string
|
|
47
|
+
}
|
|
48
|
+
const PMTILES_URL = values["pmtiles-url"] || "http://localhost:8899/race-dots-oc.pmtiles"
|
|
49
|
+
const OUT = values["out"] || "/tmp/race-dots-oc.html"
|
|
50
|
+
const PER = Number(values["per"] || "5")
|
|
51
|
+
const PER_PHRASE = PER === 1 ? "one dot is one person" : `one dot ≈ ${PER} people`
|
|
52
|
+
const TITLE = values["title"] || `Race in Orange County, CA — ${PER_PHRASE} (2020 Census)`
|
|
53
|
+
const CENTER_LNG = Number(values["lng"] || "-117.83")
|
|
54
|
+
const CENTER_LAT = Number(values["lat"] || "33.68")
|
|
55
|
+
const ZOOM = Number(values["zoom"] || "9.4")
|
|
56
|
+
|
|
57
|
+
const MAPLIBRE_VERSION = "5.24.0"
|
|
58
|
+
const MAPLIBRE_JS_SRI = "sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp"
|
|
59
|
+
const MAPLIBRE_CSS_SRI = "sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK"
|
|
60
|
+
const PMTILES_VERSION = "4.4.1"
|
|
61
|
+
|
|
62
|
+
const BASEMAP_SOURCE_ID = "basemap-v4"
|
|
63
|
+
const BASEMAP_TILEJSON_URL = "https://tiles.sister.software/basemap-v4.json"
|
|
64
|
+
const GLYPHS_URL = "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf"
|
|
65
|
+
const SPRITE_URL = "https://protomaps.github.io/basemaps-assets/sprites/v4/light"
|
|
66
|
+
|
|
67
|
+
// Cooper Center "Racial Dot Map" palette, by `pl_block`/P2 category.
|
|
68
|
+
const CATEGORY_COLOR: Record<string, string> = {
|
|
69
|
+
white: "#1f78b4", // blue
|
|
70
|
+
black: "#33a02c", // green
|
|
71
|
+
hispanic: "#ff7f00", // orange
|
|
72
|
+
asian: "#e31a1c", // red
|
|
73
|
+
aian: "#6a3d9a", // purple
|
|
74
|
+
nhpi: "#00b3b3", // teal
|
|
75
|
+
other: "#b15928", // brown
|
|
76
|
+
multi: "#9e9e9e", // grey
|
|
77
|
+
}
|
|
78
|
+
const CATEGORY_LABEL: Record<string, string> = {
|
|
79
|
+
white: "White (non-Hispanic)",
|
|
80
|
+
black: "Black (non-Hispanic)",
|
|
81
|
+
hispanic: "Hispanic or Latino",
|
|
82
|
+
asian: "Asian (non-Hispanic)",
|
|
83
|
+
aian: "Native American",
|
|
84
|
+
nhpi: "Pacific Islander",
|
|
85
|
+
other: "Other (non-Hispanic)",
|
|
86
|
+
multi: "Two or more races",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const colorMatch: unknown[] = ["match", ["get", "cat"]]
|
|
90
|
+
|
|
91
|
+
for (const [cat, color] of Object.entries(CATEGORY_COLOR)) {
|
|
92
|
+
colorMatch.push(cat, color)
|
|
93
|
+
}
|
|
94
|
+
colorMatch.push("#9e9e9e")
|
|
95
|
+
|
|
96
|
+
const style = {
|
|
97
|
+
version: 8,
|
|
98
|
+
glyphs: GLYPHS_URL,
|
|
99
|
+
sprite: SPRITE_URL,
|
|
100
|
+
sources: {
|
|
101
|
+
[BASEMAP_SOURCE_ID]: { type: "vector", url: BASEMAP_TILEJSON_URL },
|
|
102
|
+
dots: { type: "vector", url: "pmtiles://" + PMTILES_URL },
|
|
103
|
+
},
|
|
104
|
+
layers: [
|
|
105
|
+
...(layers(BASEMAP_SOURCE_ID, namedFlavor("light"), { lang: "en" }) as unknown[]),
|
|
106
|
+
{
|
|
107
|
+
id: "race-dots",
|
|
108
|
+
type: "circle",
|
|
109
|
+
source: "dots",
|
|
110
|
+
"source-layer": "dots",
|
|
111
|
+
paint: {
|
|
112
|
+
"circle-radius": ["interpolate", ["linear"], ["zoom"], 4, 0.7, 9, 1.7, 12, 2.8, 16, 4.5],
|
|
113
|
+
"circle-color": colorMatch,
|
|
114
|
+
"circle-opacity": 0.85,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const legendRows = Object.keys(CATEGORY_COLOR)
|
|
121
|
+
.map((c) => `<div><i style="background:${CATEGORY_COLOR[c]}"></i>${CATEGORY_LABEL[c]}</div>`)
|
|
122
|
+
.join("")
|
|
123
|
+
|
|
124
|
+
// The client script avoids template literals / `${` so it survives this outer template verbatim.
|
|
125
|
+
const html = `<!doctype html>
|
|
126
|
+
<html lang="en">
|
|
127
|
+
<head>
|
|
128
|
+
<meta charset="utf-8" />
|
|
129
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
130
|
+
<title>${TITLE}</title>
|
|
131
|
+
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@${MAPLIBRE_VERSION}/dist/maplibre-gl.css" integrity="${MAPLIBRE_CSS_SRI}" crossorigin="anonymous" />
|
|
132
|
+
<style>
|
|
133
|
+
html, body { margin: 0; height: 100%; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
134
|
+
#map { position: absolute; inset: 0; background: #f4f4f2; }
|
|
135
|
+
.mw-panel { position: absolute; z-index: 1; background: rgba(255,255,255,0.94); padding: 10px 12px; border-radius: 8px; box-shadow: 0 1px 6px rgba(0,0,0,0.3); font-size: 12px; line-height: 1.5; color: #1a1a1a; }
|
|
136
|
+
.mw-title { top: 10px; left: 10px; max-width: 64%; }
|
|
137
|
+
.mw-title h1 { font-size: 14px; margin: 0 0 4px; }
|
|
138
|
+
.mw-legend { bottom: 22px; right: 10px; }
|
|
139
|
+
.mw-legend i { display: inline-block; width: 11px; height: 11px; margin-right: 6px; border-radius: 50%; vertical-align: -1px; }
|
|
140
|
+
.muted { color: #666; }
|
|
141
|
+
</style>
|
|
142
|
+
</head>
|
|
143
|
+
<body>
|
|
144
|
+
<div id="map"></div>
|
|
145
|
+
<div class="mw-panel mw-title"><h1>${TITLE}</h1><div class="muted">Each dot is ${PER === 1 ? "a person" : `~${PER} people`}, placed at random inside their Census block. Source: 2020 P.L. 94-171 (table P2) + TIGER blocks.</div></div>
|
|
146
|
+
<div class="mw-panel mw-legend">${legendRows}</div>
|
|
147
|
+
<script src="https://unpkg.com/maplibre-gl@${MAPLIBRE_VERSION}/dist/maplibre-gl.js" integrity="${MAPLIBRE_JS_SRI}" crossorigin="anonymous"></script>
|
|
148
|
+
<script src="https://unpkg.com/pmtiles@${PMTILES_VERSION}/dist/pmtiles.js" crossorigin="anonymous"></script>
|
|
149
|
+
<script>
|
|
150
|
+
var protocol = new pmtiles.Protocol();
|
|
151
|
+
maplibregl.addProtocol("pmtiles", protocol.tile);
|
|
152
|
+
var map = new maplibregl.Map({
|
|
153
|
+
container: "map",
|
|
154
|
+
style: ${JSON.stringify(style)},
|
|
155
|
+
center: [${CENTER_LNG}, ${CENTER_LAT}],
|
|
156
|
+
zoom: ${ZOOM},
|
|
157
|
+
attributionControl: false
|
|
158
|
+
});
|
|
159
|
+
map.addControl(new maplibregl.AttributionControl({ compact: true, customAttribution: "US Census 2020 · TIGER · Protomaps · OpenStreetMap" }));
|
|
160
|
+
</script>
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|
|
163
|
+
`
|
|
164
|
+
|
|
165
|
+
writeFileSync(OUT, html)
|
|
166
|
+
console.error(`[written] ${OUT} (pmtiles: ${PMTILES_URL})`)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Race-by-dot-density builder — the Cooper Center "Racial Dot Map" recipe, from the TIGER DB the
|
|
7
|
+
* `mailwoman tiger` CLI produces.
|
|
8
|
+
*
|
|
9
|
+
* Reads `tabblock20 ⋈ pl_block` (block geometry + Census 2020 P.L. 94-171 table P2 counts) and
|
|
10
|
+
* scatters one dot per `--per` people uniformly at random inside each block, tagged with its
|
|
11
|
+
* race/ethnicity category. Output is NDJSON (one GeoJSON Point Feature per line, with a
|
|
12
|
+
* `tippecanoe` layer hint) ready for `tippecanoe -o race-dots.pmtiles`.
|
|
13
|
+
*
|
|
14
|
+
* Point-in-polygon uses `@turf/boolean-contains` (ships with `@mailwoman/tiger`). The dot is a
|
|
15
|
+
* _representation_, not a record: a random position inside the block it belongs to, standing in
|
|
16
|
+
* for `--per` real people of that category. It says nothing about any individual address.
|
|
17
|
+
*
|
|
18
|
+
* Build the input DB first: mailwoman tiger fetch --state 06 --county 059 --out tiger-oc.db
|
|
19
|
+
* mailwoman tiger redistricting --state 06 --county 059 --out tiger-oc.db
|
|
20
|
+
*
|
|
21
|
+
* Run: node --experimental-strip-types scripts/census/race-dots.ts\
|
|
22
|
+
* --db tiger-oc.db --per 10 --out /tmp/race-dots.ndjson
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createWriteStream } from "node:fs"
|
|
26
|
+
import { DatabaseSync } from "node:sqlite"
|
|
27
|
+
import { parseArgs } from "node:util"
|
|
28
|
+
|
|
29
|
+
import { dataRootPath } from "@mailwoman/core/utils"
|
|
30
|
+
import booleanContains from "@turf/boolean-contains"
|
|
31
|
+
|
|
32
|
+
// Loose scan parity with the retired local argv helpers: unknown flags tolerated.
|
|
33
|
+
const { values: rawValues } = parseArgs({
|
|
34
|
+
options: { db: { type: "string" }, layer: { type: "string" }, out: { type: "string" }, per: { type: "string" } },
|
|
35
|
+
strict: false,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
})
|
|
38
|
+
// Typed view: strict:false loosens TS inference, but declared options always parse to their schema type.
|
|
39
|
+
const values = rawValues as { db?: string; layer?: string; out?: string; per?: string }
|
|
40
|
+
const DB = values["db"] || dataRootPath("tiger", "tiger-oc.db")
|
|
41
|
+
const OUT = values["out"] || "/tmp/race-dots.ndjson"
|
|
42
|
+
const PER = Number(values["per"] || "10") // people represented by one dot
|
|
43
|
+
const LAYER = values["layer"] || "dots"
|
|
44
|
+
|
|
45
|
+
// The eight P2 categories (columns in pl_block) that partition each block's population.
|
|
46
|
+
const CATEGORIES = ["hispanic", "white", "black", "asian", "aian", "nhpi", "other", "multi"] as const
|
|
47
|
+
|
|
48
|
+
type Ring = number[][]
|
|
49
|
+
type PolygonCoords = Ring[]
|
|
50
|
+
|
|
51
|
+
function bbox(rings: PolygonCoords): [number, number, number, number] {
|
|
52
|
+
let minX = Infinity,
|
|
53
|
+
minY = Infinity,
|
|
54
|
+
maxX = -Infinity,
|
|
55
|
+
maxY = -Infinity
|
|
56
|
+
|
|
57
|
+
for (const [x, y] of rings[0]!) {
|
|
58
|
+
if (x! < minX) {
|
|
59
|
+
minX = x!
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (x! > maxX) {
|
|
63
|
+
maxX = x!
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (y! < minY) {
|
|
67
|
+
minY = y!
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (y! > maxY) {
|
|
71
|
+
maxY = y!
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [minX, minY, maxX, maxY]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A block geometry is one or more polygons. Pick a sub-polygon weighted by bbox area, then
|
|
79
|
+
// rejection-sample inside it with a turf containment test (handles holes + winding correctly).
|
|
80
|
+
function randomPointIn(polys: PolygonCoords[], areas: number[], totalArea: number): [number, number] | null {
|
|
81
|
+
let r = Math.random() * totalArea
|
|
82
|
+
let pick = 0
|
|
83
|
+
|
|
84
|
+
while (pick < polys.length - 1 && (r -= areas[pick]!) > 0) {
|
|
85
|
+
pick++
|
|
86
|
+
}
|
|
87
|
+
const poly = polys[pick]!
|
|
88
|
+
const polyFeature = {
|
|
89
|
+
type: "Feature" as const,
|
|
90
|
+
geometry: { type: "Polygon" as const, coordinates: poly },
|
|
91
|
+
properties: {},
|
|
92
|
+
}
|
|
93
|
+
const [minX, minY, maxX, maxY] = bbox(poly)
|
|
94
|
+
|
|
95
|
+
for (let tries = 0; tries < 60; tries++) {
|
|
96
|
+
const x = minX + Math.random() * (maxX - minX)
|
|
97
|
+
const y = minY + Math.random() * (maxY - minY)
|
|
98
|
+
const pt = { type: "Feature" as const, geometry: { type: "Point" as const, coordinates: [x, y] }, properties: {} }
|
|
99
|
+
|
|
100
|
+
if (booleanContains(polyFeature, pt)) return [x, y]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const db = new DatabaseSync(DB, { readOnly: true })
|
|
107
|
+
const rows = db
|
|
108
|
+
.prepare(
|
|
109
|
+
`SELECT b.geometry AS geometry, ${CATEGORIES.map((c) => `p.${c} AS ${c}`).join(", ")}
|
|
110
|
+
FROM tabblock20 b JOIN pl_block p ON b.GEOID = p.GEOID
|
|
111
|
+
WHERE p.pop_total > 0`
|
|
112
|
+
)
|
|
113
|
+
.all() as Array<{ geometry: string } & Record<(typeof CATEGORIES)[number], number>>
|
|
114
|
+
db.close()
|
|
115
|
+
|
|
116
|
+
const out = createWriteStream(OUT)
|
|
117
|
+
const totals = new Map<string, number>()
|
|
118
|
+
let dots = 0,
|
|
119
|
+
skipped = 0
|
|
120
|
+
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
let geom: { type: string; coordinates: unknown }
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
geom = JSON.parse(row.geometry)
|
|
126
|
+
} catch {
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const polys: PolygonCoords[] =
|
|
130
|
+
geom.type === "Polygon" ? [geom.coordinates as PolygonCoords] : (geom.coordinates as PolygonCoords[])
|
|
131
|
+
const areas = polys.map((p) => {
|
|
132
|
+
const [a, b, c, d] = bbox(p)
|
|
133
|
+
|
|
134
|
+
return Math.max((c - a) * (d - b), 1e-12)
|
|
135
|
+
})
|
|
136
|
+
const totalArea = areas.reduce((s, a) => s + a, 0)
|
|
137
|
+
|
|
138
|
+
for (const cat of CATEGORIES) {
|
|
139
|
+
const people = row[cat]
|
|
140
|
+
|
|
141
|
+
if (people <= 0) continue
|
|
142
|
+
const exact = people / PER
|
|
143
|
+
const n = Math.floor(exact) + (Math.random() < exact - Math.floor(exact) ? 1 : 0)
|
|
144
|
+
|
|
145
|
+
for (let k = 0; k < n; k++) {
|
|
146
|
+
const pt = randomPointIn(polys, areas, totalArea)
|
|
147
|
+
|
|
148
|
+
if (!pt) {
|
|
149
|
+
skipped++
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
out.write(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
type: "Feature",
|
|
155
|
+
tippecanoe: { layer: LAYER },
|
|
156
|
+
properties: { cat },
|
|
157
|
+
geometry: { type: "Point", coordinates: [Math.round(pt[0] * 1e5) / 1e5, Math.round(pt[1] * 1e5) / 1e5] },
|
|
158
|
+
}) + "\n"
|
|
159
|
+
)
|
|
160
|
+
dots++
|
|
161
|
+
totals.set(cat, (totals.get(cat) ?? 0) + 1)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
out.end()
|
|
167
|
+
console.error(`[done] ${dots} dots from ${rows.length} blocks (1 dot ≈ ${PER} people); ${skipped} skipped`)
|
|
168
|
+
|
|
169
|
+
for (const [cat, n] of [...totals.entries()].sort((a, b) => b[1] - a[1])) {
|
|
170
|
+
console.error(` ${n.toString().padStart(7)} ${cat}`)
|
|
171
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Minimal static file server that honours HTTP Range requests (206) — Python's `http.server` does
|
|
7
|
+
* not, and PMTiles reads via Range, so it can't be served by it. Used to preview/render a local
|
|
8
|
+
* `.pmtiles` tileset (e.g. the race-dot map). Not for production; the deployed tiles go through
|
|
9
|
+
* the tile worker.
|
|
10
|
+
*
|
|
11
|
+
* Run: node scripts/census/serve-range.ts [dir=/tmp] [port=8899]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createReadStream, statSync, type Stats } from "node:fs"
|
|
15
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
|
|
16
|
+
import { extname, join, normalize } from "node:path"
|
|
17
|
+
import { parseArgs } from "node:util"
|
|
18
|
+
|
|
19
|
+
const { positionals } = parseArgs({ allowPositionals: true, strict: false })
|
|
20
|
+
const DIR = positionals[0] || "/tmp"
|
|
21
|
+
const PORT = Number(positionals[1] || 8899)
|
|
22
|
+
|
|
23
|
+
const TYPES: Record<string, string> = {
|
|
24
|
+
".html": "text/html; charset=utf-8",
|
|
25
|
+
".json": "application/json",
|
|
26
|
+
".pmtiles": "application/octet-stream",
|
|
27
|
+
".pbf": "application/x-protobuf",
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
32
|
+
const rel = normalize(decodeURIComponent((req.url || "/").split("?")[0]!)).replace(/^(\.\.[/\\])+/, "")
|
|
33
|
+
const path = join(DIR, rel)
|
|
34
|
+
let st: Stats
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
st = statSync(path)
|
|
38
|
+
} catch {
|
|
39
|
+
res.writeHead(404)
|
|
40
|
+
|
|
41
|
+
return res.end("not found")
|
|
42
|
+
}
|
|
43
|
+
const type = TYPES[extname(path)] || "application/octet-stream"
|
|
44
|
+
const base = { "Content-Type": type, "Accept-Ranges": "bytes", "Access-Control-Allow-Origin": "*" }
|
|
45
|
+
const range = req.headers.range
|
|
46
|
+
const m = range && /bytes=(\d+)-(\d*)/.exec(range)
|
|
47
|
+
|
|
48
|
+
if (m) {
|
|
49
|
+
const start = Number(m[1])
|
|
50
|
+
const end = m[2] ? Number(m[2]) : st.size - 1
|
|
51
|
+
res.writeHead(206, {
|
|
52
|
+
...base,
|
|
53
|
+
"Content-Range": `bytes ${start}-${end}/${st.size}`,
|
|
54
|
+
"Content-Length": end - start + 1,
|
|
55
|
+
})
|
|
56
|
+
createReadStream(path, { start, end }).pipe(res)
|
|
57
|
+
} else {
|
|
58
|
+
res.writeHead(200, { ...base, "Content-Length": st.size })
|
|
59
|
+
createReadStream(path).pipe(res)
|
|
60
|
+
}
|
|
61
|
+
}).listen(PORT, () => console.error(`range server: ${DIR} on http://localhost:${PORT}`))
|