@mailwoman/tiger 5.8.0 → 5.10.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.
Files changed (80) hide show
  1. package/block-group.ts +4 -4
  2. package/class-code.ts +168 -166
  3. package/constants.ts +35 -28
  4. package/county-subdivision.ts +4 -4
  5. package/county.ts +4 -4
  6. package/files.ts +41 -32
  7. package/functional-status.ts +16 -14
  8. package/geoid.ts +13 -11
  9. package/index.ts +12 -12
  10. package/legal-statistical-area.ts +140 -137
  11. package/out/block-group.d.ts +4 -4
  12. package/out/class-code.d.ts +167 -166
  13. package/out/class-code.d.ts.map +1 -1
  14. package/out/class-code.js +166 -167
  15. package/out/class-code.js.map +1 -1
  16. package/out/constants.d.ts +31 -28
  17. package/out/constants.d.ts.map +1 -1
  18. package/out/constants.js +23 -26
  19. package/out/constants.js.map +1 -1
  20. package/out/county-subdivision.d.ts +4 -4
  21. package/out/county.d.ts +4 -4
  22. package/out/files.d.ts +35 -33
  23. package/out/files.d.ts.map +1 -1
  24. package/out/files.js +19 -21
  25. package/out/files.js.map +1 -1
  26. package/out/functional-status.d.ts +15 -14
  27. package/out/functional-status.d.ts.map +1 -1
  28. package/out/functional-status.js +14 -15
  29. package/out/functional-status.js.map +1 -1
  30. package/out/geoid.d.ts +20 -19
  31. package/out/geoid.d.ts.map +1 -1
  32. package/out/geoid.js +10 -11
  33. package/out/geoid.js.map +1 -1
  34. package/out/index.d.ts +12 -12
  35. package/out/legal-statistical-area.d.ts +138 -137
  36. package/out/legal-statistical-area.d.ts.map +1 -1
  37. package/out/legal-statistical-area.js +137 -138
  38. package/out/legal-statistical-area.js.map +1 -1
  39. package/out/sdk/index.d.ts +4 -4
  40. package/out/sdk/state/TIGERTabulatedBlockEntity.d.ts +1 -2
  41. package/out/sdk/state/TIGERTabulatedBlockEntity.d.ts.map +1 -1
  42. package/out/sdk/state/index.d.ts +3 -3
  43. package/out/state.d.ts +302 -297
  44. package/out/state.d.ts.map +1 -1
  45. package/out/state.js +181 -186
  46. package/out/state.js.map +1 -1
  47. package/out/tabulation-block.d.ts +3 -3
  48. package/out/tabulation-block.d.ts.map +1 -1
  49. package/out/tools/index.d.ts +15 -0
  50. package/out/tools/index.d.ts.map +1 -0
  51. package/out/tools/index.js +15 -0
  52. package/out/tools/index.js.map +1 -0
  53. package/out/tools/race-dots-map.d.ts +29 -5
  54. package/out/tools/race-dots-map.d.ts.map +1 -1
  55. package/out/tools/race-dots-map.js +53 -64
  56. package/out/tools/race-dots-map.js.map +1 -1
  57. package/out/tools/race-dots.d.ts +27 -7
  58. package/out/tools/race-dots.d.ts.map +1 -1
  59. package/out/tools/race-dots.js +85 -89
  60. package/out/tools/race-dots.js.map +1 -1
  61. package/out/tools/serve-range.d.ts +23 -4
  62. package/out/tools/serve-range.d.ts.map +1 -1
  63. package/out/tools/serve-range.js +47 -36
  64. package/out/tools/serve-range.js.map +1 -1
  65. package/out/tract.d.ts +3 -3
  66. package/out/tract.d.ts.map +1 -1
  67. package/out/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +31 -6
  69. package/sdk/fetch.ts +2 -2
  70. package/sdk/index.ts +4 -4
  71. package/sdk/redistricting.ts +2 -2
  72. package/sdk/state/index.ts +3 -3
  73. package/state.ts +193 -183
  74. package/tabulation-block.ts +20 -20
  75. package/tools/index.ts +15 -0
  76. package/tools/race-dots-map.ts +79 -70
  77. package/tools/race-dots.ts +127 -104
  78. package/tools/serve-range.ts +69 -38
  79. package/tract.ts +12 -12
  80. package/tsconfig.json +2 -1
@@ -6,32 +6,32 @@
6
6
 
7
7
  import { type MultiPolygonLiteral } from "@mailwoman/spatial"
8
8
 
9
- import { TIGERProperty, type TIGERPropertyRecord } from "./constants.js"
10
- import { GeoIDPart, type FIPSBlockGeoID, type ParsedGeoIDBlockLevel } from "./geoid.js"
9
+ import { TIGERProperty, type TIGERPropertyRecord } from "./constants.ts"
10
+ import { GeoIDPart, type FIPSBlockGeoID, type ParsedGeoIDBlockLevel } from "./geoid.ts"
11
11
 
12
12
  /**
13
13
  * The properties of a tabulated block from the TIGER/Line dataset.
14
14
  */
15
15
  export type TIGERTabulatedBlockProperties = Pick<
16
16
  TIGERPropertyRecord<FIPSBlockGeoID>,
17
- | TIGERProperty.GeoID
18
- | GeoIDPart.CountySubDivision
19
- | GeoIDPart.Tract
20
- | GeoIDPart.Place
21
- | GeoIDPart.BlockGroup
22
- | GeoIDPart.Block
23
- | GeoIDPart.Place
24
- | GeoIDPart.CongressionalDistrict
25
- | TIGERProperty.ClassCode
26
- | TIGERProperty.UrbanRuralCode
27
- | TIGERProperty.UrbanizedAreaCode
28
- | TIGERProperty.FunctionalStatus
29
- | TIGERProperty.LandAreaSqm
30
- | TIGERProperty.WaterAreaSqm
31
- | TIGERProperty.CentroidLatitude
32
- | TIGERProperty.CentroidLongitude
33
- | TIGERProperty.HousingUnitCount
34
- | TIGERProperty.Population
17
+ | typeof TIGERProperty.GeoID
18
+ | typeof GeoIDPart.CountySubDivision
19
+ | typeof GeoIDPart.Tract
20
+ | typeof GeoIDPart.Place
21
+ | typeof GeoIDPart.BlockGroup
22
+ | typeof GeoIDPart.Block
23
+ | typeof GeoIDPart.Place
24
+ | typeof GeoIDPart.CongressionalDistrict
25
+ | typeof TIGERProperty.ClassCode
26
+ | typeof TIGERProperty.UrbanRuralCode
27
+ | typeof TIGERProperty.UrbanizedAreaCode
28
+ | typeof TIGERProperty.FunctionalStatus
29
+ | typeof TIGERProperty.LandAreaSqm
30
+ | typeof TIGERProperty.WaterAreaSqm
31
+ | typeof TIGERProperty.CentroidLatitude
32
+ | typeof TIGERProperty.CentroidLongitude
33
+ | typeof TIGERProperty.HousingUnitCount
34
+ | typeof TIGERProperty.Population
35
35
  >
36
36
 
37
37
  export interface TIGERBlockFeature {
package/tools/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * TIGER operator tools — the `run()`-style modules behind `mailwoman tiger race-dots` /
7
+ * `race-dots-map` (see the 2026-07-09 scripts→Pastel spec). No argv, no `process.exit`: commands
8
+ * own parsing, rendering, and exit codes. Heavy deps (`@turf/boolean-contains`,
9
+ * `@protomaps/basemaps`) are lazy-imported inside their entry fns. NOT `sdk/` — that submodule
10
+ * means data acquisition.
11
+ */
12
+
13
+ export * from "./race-dots.ts"
14
+ export * from "./race-dots-map.ts"
15
+ export * from "./serve-range.ts"
@@ -8,51 +8,40 @@
8
8
  * inlined here, à la `registry/map-html.ts`) under a circle layer of the dots, colored by race
9
9
  * category. The dots PMTiles is read client-side via the `pmtiles` protocol.
10
10
  *
11
- * Each dot is one of `--per` people of a category, placed at random inside its Census block — a
11
+ * Each dot is one of `per` people of a category, placed at random inside its Census block — a
12
12
  * representation, not a record about any address. Serve over localhost (the house tile server
13
- * CORS-restricts to localhost + the docs domains).
13
+ * CORS-restricts to localhost + the docs domains) — `mailwoman tiger race-dots-map --serve` wires
14
+ * {@linkcode serveWithRangeSupport} for exactly this.
14
15
  *
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
16
+ * Run: `mailwoman tiger race-dots-map --pmtiles-url
17
+ * http://localhost:8899/race-dots-oc.pmtiles --out /tmp/race-dots-oc.html`
17
18
  */
18
19
 
19
20
  import { writeFileSync } from "node:fs"
20
- import { parseArgs } from "node:util"
21
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
22
+ /** Options for {@linkcode raceDotsMap}. */
23
+ export interface RaceDotsMapOptions {
24
+ /** Dots tileset URL the page reads client-side. Default `http://localhost:8899/race-dots-oc.pmtiles`. */
25
+ pmtilesURL?: string
26
+ /** Output HTML path. Default `/tmp/race-dots-oc.html`. */
42
27
  out?: string
43
- per?: string
44
- "pmtiles-url"?: string
28
+ /** People represented by one dot (title/legend copy only). Default 5. */
29
+ per?: number
30
+ /** Page title. Default derives from `per`. */
45
31
  title?: string
46
- zoom?: string
32
+ /** Initial map center longitude. Default -117.83 (Orange County, CA). */
33
+ lng?: number
34
+ /** Initial map center latitude. Default 33.68. */
35
+ lat?: number
36
+ /** Initial map zoom. Default 9.4. */
37
+ zoom?: number
38
+ }
39
+
40
+ /** Result of {@linkcode raceDotsMap}. */
41
+ export interface RaceDotsMapResult {
42
+ outPath: string
43
+ pmtilesURL: string
47
44
  }
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
45
 
57
46
  const MAPLIBRE_VERSION = "5.24.0"
58
47
  const MAPLIBRE_JS_SRI = "sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp"
@@ -86,43 +75,60 @@ const CATEGORY_LABEL: Record<string, string> = {
86
75
  multi: "Two or more races",
87
76
  }
88
77
 
89
- const colorMatch: unknown[] = ["match", ["get", "cat"]]
78
+ /** Race-dots MapLibre page writer — see the module doc. */
79
+ export async function raceDotsMap(
80
+ options: RaceDotsMapOptions = {},
81
+ report?: (line: string) => void
82
+ ): Promise<RaceDotsMapResult> {
83
+ const PMTILES_URL = options.pmtilesURL || "http://localhost:8899/race-dots-oc.pmtiles"
84
+ const OUT = options.out || "/tmp/race-dots-oc.html"
85
+ const PER = options.per ?? 5
86
+ const PER_PHRASE = PER === 1 ? "one dot is one person" : `one dot ≈ ${PER} people`
87
+ const TITLE = options.title || `Race in Orange County, CA — ${PER_PHRASE} (2020 Census)`
88
+ const CENTER_LNG = options.lng ?? -117.83
89
+ const CENTER_LAT = options.lat ?? 33.68
90
+ const ZOOM = options.zoom ?? 9.4
90
91
 
91
- for (const [cat, color] of Object.entries(CATEGORY_COLOR)) {
92
- colorMatch.push(cat, color)
93
- }
94
- colorMatch.push("#9e9e9e")
92
+ // Heavy dep (devDependency — operator tooling), lazy-imported so loading the tools barrel stays cheap.
93
+ const { layers, namedFlavor } = await import("@protomaps/basemaps")
95
94
 
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
- },
95
+ const colorMatch: unknown[] = ["match", ["get", "cat"]]
96
+
97
+ for (const [cat, color] of Object.entries(CATEGORY_COLOR)) {
98
+ colorMatch.push(cat, color)
99
+ }
100
+ colorMatch.push("#9e9e9e")
101
+
102
+ const style = {
103
+ version: 8,
104
+ glyphs: GLYPHS_URL,
105
+ sprite: SPRITE_URL,
106
+ sources: {
107
+ [BASEMAP_SOURCE_ID]: { type: "vector", url: BASEMAP_TILEJSON_URL },
108
+ dots: { type: "vector", url: "pmtiles://" + PMTILES_URL },
116
109
  },
117
- ],
118
- }
110
+ layers: [
111
+ ...(layers(BASEMAP_SOURCE_ID, namedFlavor("light"), { lang: "en" }) as unknown[]),
112
+ {
113
+ id: "race-dots",
114
+ type: "circle",
115
+ source: "dots",
116
+ "source-layer": "dots",
117
+ paint: {
118
+ "circle-radius": ["interpolate", ["linear"], ["zoom"], 4, 0.7, 9, 1.7, 12, 2.8, 16, 4.5],
119
+ "circle-color": colorMatch,
120
+ "circle-opacity": 0.85,
121
+ },
122
+ },
123
+ ],
124
+ }
119
125
 
120
- const legendRows = Object.keys(CATEGORY_COLOR)
121
- .map((c) => `<div><i style="background:${CATEGORY_COLOR[c]}"></i>${CATEGORY_LABEL[c]}</div>`)
122
- .join("")
126
+ const legendRows = Object.keys(CATEGORY_COLOR)
127
+ .map((c) => `<div><i style="background:${CATEGORY_COLOR[c]}"></i>${CATEGORY_LABEL[c]}</div>`)
128
+ .join("")
123
129
 
124
- // The client script avoids template literals / `${` so it survives this outer template verbatim.
125
- const html = `<!doctype html>
130
+ // The client script avoids template literals / `${` so it survives this outer template verbatim.
131
+ const html = `<!doctype html>
126
132
  <html lang="en">
127
133
  <head>
128
134
  <meta charset="utf-8" />
@@ -162,5 +168,8 @@ const html = `<!doctype html>
162
168
  </html>
163
169
  `
164
170
 
165
- writeFileSync(OUT, html)
166
- console.error(`[written] ${OUT} (pmtiles: ${PMTILES_URL})`)
171
+ writeFileSync(OUT, html)
172
+ report?.(`[written] ${OUT} (pmtiles: ${PMTILES_URL})`)
173
+
174
+ return { outPath: OUT, pmtilesURL: PMTILES_URL }
175
+ }
@@ -7,40 +7,46 @@
7
7
  * `mailwoman tiger` CLI produces.
8
8
  *
9
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
10
+ * scatters one dot per `per` people uniformly at random inside each block, tagged with its
11
11
  * race/ethnicity category. Output is NDJSON (one GeoJSON Point Feature per line, with a
12
12
  * `tippecanoe` layer hint) ready for `tippecanoe -o race-dots.pmtiles`.
13
13
  *
14
14
  * Point-in-polygon uses `@turf/boolean-contains` (ships with `@mailwoman/tiger`). The dot is a
15
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.
16
+ * for `per` real people of that category. It says nothing about any individual address.
17
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
18
+ * Build the input DB first: `mailwoman tiger fetch --state 06 --county 059 --out tiger-oc.db` then
19
+ * `mailwoman tiger redistricting --state 06 --county 059 --out tiger-oc.db`.
20
20
  *
21
- * Run: node --experimental-strip-types scripts/census/race-dots.ts\
22
- * --db tiger-oc.db --per 10 --out /tmp/race-dots.ndjson
21
+ * Run: `mailwoman tiger race-dots --db tiger-oc.db --per 10 --out /tmp/race-dots.ndjson`
23
22
  */
24
23
 
25
24
  import { createWriteStream } from "node:fs"
26
25
  import { DatabaseSync } from "node:sqlite"
27
- import { parseArgs } from "node:util"
28
26
 
29
27
  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"
28
+
29
+ /** Options for {@linkcode raceDots}. */
30
+ export interface RaceDotsOptions {
31
+ /** TIGER SQLite DB (`tabblock20` ⋈ `pl_block`). Default `$MAILWOMAN_DATA_ROOT/tiger/tiger-oc.db`. */
32
+ db?: string
33
+ /** Output NDJSON path. Default `/tmp/race-dots.ndjson`. */
34
+ out?: string
35
+ /** People represented by one dot. Default 10. */
36
+ per?: number
37
+ /** Tippecanoe layer name. Default `dots`. */
38
+ layer?: string
39
+ }
40
+
41
+ /** Result of {@linkcode raceDots}. */
42
+ export interface RaceDotsResult {
43
+ outPath: string
44
+ dots: number
45
+ skipped: number
46
+ blocks: number
47
+ /** Dots emitted per P2 category. */
48
+ totals: Record<string, number>
49
+ }
44
50
 
45
51
  // The eight P2 categories (columns in pl_block) that partition each block's population.
46
52
  const CATEGORIES = ["hispanic", "white", "black", "asian", "aian", "nhpi", "other", "multi"] as const
@@ -75,97 +81,114 @@ function bbox(rings: PolygonCoords): [number, number, number, number] {
75
81
  return [minX, minY, maxX, maxY]
76
82
  }
77
83
 
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: {} }
84
+ /** Race-by-dot-density NDJSON builder — see the module doc. */
85
+ export async function raceDots(
86
+ options: RaceDotsOptions = {},
87
+ report?: (line: string) => void
88
+ ): Promise<RaceDotsResult> {
89
+ const DB = options.db || dataRootPath("tiger", "tiger-oc.db")
90
+ const OUT = options.out || "/tmp/race-dots.ndjson"
91
+ const PER = options.per ?? 10 // people represented by one dot
92
+ const LAYER = options.layer || "dots"
93
+
94
+ // Heavy dep, lazy-imported so loading the tools barrel stays cheap.
95
+ const { default: booleanContains } = await import("@turf/boolean-contains")
96
+
97
+ // A block geometry is one or more polygons. Pick a sub-polygon weighted by bbox area, then
98
+ // rejection-sample inside it with a turf containment test (handles holes + winding correctly).
99
+ function randomPointIn(polys: PolygonCoords[], areas: number[], totalArea: number): [number, number] | null {
100
+ let r = Math.random() * totalArea
101
+ let pick = 0
102
+
103
+ while (pick < polys.length - 1 && (r -= areas[pick]!) > 0) {
104
+ pick++
105
+ }
106
+ const poly = polys[pick]!
107
+ const polyFeature = {
108
+ type: "Feature" as const,
109
+ geometry: { type: "Polygon" as const, coordinates: poly },
110
+ properties: {},
111
+ }
112
+ const [minX, minY, maxX, maxY] = bbox(poly)
99
113
 
100
- if (booleanContains(polyFeature, pt)) return [x, y]
101
- }
114
+ for (let tries = 0; tries < 60; tries++) {
115
+ const x = minX + Math.random() * (maxX - minX)
116
+ const y = minY + Math.random() * (maxY - minY)
117
+ const pt = { type: "Feature" as const, geometry: { type: "Point" as const, coordinates: [x, y] }, properties: {} }
102
118
 
103
- return null
104
- }
119
+ if (booleanContains(polyFeature, pt)) return [x, y]
120
+ }
105
121
 
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
122
+ return null
128
123
  }
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
124
 
145
- for (let k = 0; k < n; k++) {
146
- const pt = randomPointIn(polys, areas, totalArea)
147
-
148
- if (!pt) {
149
- skipped++
150
- continue
125
+ const db = new DatabaseSync(DB, { readOnly: true })
126
+ const rows = db
127
+ .prepare(
128
+ `SELECT b.geometry AS geometry, ${CATEGORIES.map((c) => `p.${c} AS ${c}`).join(", ")}
129
+ FROM tabblock20 b JOIN pl_block p ON b.GEOID = p.GEOID
130
+ WHERE p.pop_total > 0`
131
+ )
132
+ .all() as Array<{ geometry: string } & Record<(typeof CATEGORIES)[number], number>>
133
+ db.close()
134
+
135
+ const out = createWriteStream(OUT)
136
+ const totals = new Map<string, number>()
137
+ let dots = 0,
138
+ skipped = 0
139
+
140
+ for (const row of rows) {
141
+ let geom: { type: string; coordinates: unknown }
142
+
143
+ try {
144
+ geom = JSON.parse(row.geometry)
145
+ } catch {
146
+ continue
147
+ }
148
+ const polys: PolygonCoords[] =
149
+ geom.type === "Polygon" ? [geom.coordinates as PolygonCoords] : (geom.coordinates as PolygonCoords[])
150
+ const areas = polys.map((p) => {
151
+ const [a, b, c, d] = bbox(p)
152
+
153
+ return Math.max((c - a) * (d - b), 1e-12)
154
+ })
155
+ const totalArea = areas.reduce((s, a) => s + a, 0)
156
+
157
+ for (const cat of CATEGORIES) {
158
+ const people = row[cat]
159
+
160
+ if (people <= 0) continue
161
+ const exact = people / PER
162
+ const n = Math.floor(exact) + (Math.random() < exact - Math.floor(exact) ? 1 : 0)
163
+
164
+ for (let k = 0; k < n; k++) {
165
+ const pt = randomPointIn(polys, areas, totalArea)
166
+
167
+ if (!pt) {
168
+ skipped++
169
+ continue
170
+ }
171
+ out.write(
172
+ JSON.stringify({
173
+ type: "Feature",
174
+ tippecanoe: { layer: LAYER },
175
+ properties: { cat },
176
+ geometry: { type: "Point", coordinates: [Math.round(pt[0] * 1e5) / 1e5, Math.round(pt[1] * 1e5) / 1e5] },
177
+ }) + "\n"
178
+ )
179
+ dots++
180
+ totals.set(cat, (totals.get(cat) ?? 0) + 1)
151
181
  }
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
182
  }
163
183
  }
164
- }
165
184
 
166
- out.end()
167
- console.error(`[done] ${dots} dots from ${rows.length} blocks (1 dot ≈ ${PER} people); ${skipped} skipped`)
185
+ out.end()
186
+ await new Promise<void>((resolve) => out.on("finish", () => resolve()))
187
+ report?.(`[done] ${dots} dots from ${rows.length} blocks (1 dot ≈ ${PER} people); ${skipped} skipped`)
188
+
189
+ for (const [cat, n] of [...totals.entries()].sort((a, b) => b[1] - a[1])) {
190
+ report?.(` ${n.toString().padStart(7)} ${cat}`)
191
+ }
168
192
 
169
- for (const [cat, n] of [...totals.entries()].sort((a, b) => b[1] - a[1])) {
170
- console.error(` ${n.toString().padStart(7)} ${cat}`)
193
+ return { outPath: OUT, dots, skipped, blocks: rows.length, totals: Object.fromEntries(totals) }
171
194
  }
@@ -5,20 +5,31 @@
5
5
  *
6
6
  * Minimal static file server that honours HTTP Range requests (206) — Python's `http.server` does
7
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.
8
+ * `.pmtiles` tileset (e.g. the race-dot map, via `mailwoman tiger race-dots-map --serve`). Not for
9
+ * production; the deployed tiles go through the tile worker.
10
10
  *
11
- * Run: node scripts/census/serve-range.ts [dir=/tmp] [port=8899]
11
+ * Internal helper module — no standalone command.
12
12
  */
13
13
 
14
14
  import { createReadStream, statSync, type Stats } from "node:fs"
15
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
15
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
16
16
  import { extname, join, normalize } from "node:path"
17
- import { parseArgs } from "node:util"
18
17
 
19
- const { positionals } = parseArgs({ allowPositionals: true, strict: false })
20
- const DIR = positionals[0] || "/tmp"
21
- const PORT = Number(positionals[1] || 8899)
18
+ /** Options for {@linkcode serveWithRangeSupport}. */
19
+ export interface ServeRangeOptions {
20
+ /** Directory to serve. Default `/tmp`. */
21
+ dir?: string
22
+ /** Port to listen on. Default 8899. */
23
+ port?: number
24
+ }
25
+
26
+ /** A running range-capable static server, resolved once listening. */
27
+ export interface RangeServer {
28
+ dir: string
29
+ port: number
30
+ server: Server
31
+ close: () => void
32
+ }
22
33
 
23
34
  const TYPES: Record<string, string> = {
24
35
  ".html": "text/html; charset=utf-8",
@@ -28,34 +39,54 @@ const TYPES: Record<string, string> = {
28
39
  ".png": "image/png",
29
40
  }
30
41
 
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,
42
+ /**
43
+ * Serve `dir` over localhost with HTTP Range support. Resolves once the server is listening; the caller owns the
44
+ * lifetime (`close()` to stop — commands typically hold the process open instead).
45
+ */
46
+ export function serveWithRangeSupport(
47
+ options: ServeRangeOptions = {},
48
+ report?: (line: string) => void
49
+ ): Promise<RangeServer> {
50
+ const dir = options.dir || "/tmp"
51
+ const port = options.port ?? 8899
52
+
53
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
54
+ const rel = normalize(decodeURIComponent((req.url || "/").split("?")[0]!)).replace(/^(\.\.[/\\])+/, "")
55
+ const path = join(dir, rel)
56
+ let st: Stats
57
+
58
+ try {
59
+ st = statSync(path)
60
+ } catch {
61
+ res.writeHead(404)
62
+
63
+ return res.end("not found")
64
+ }
65
+ const type = TYPES[extname(path)] || "application/octet-stream"
66
+ const base = { "Content-Type": type, "Accept-Ranges": "bytes", "Access-Control-Allow-Origin": "*" }
67
+ const range = req.headers.range
68
+ const m = range && /bytes=(\d+)-(\d*)/.exec(range)
69
+
70
+ if (m) {
71
+ const start = Number(m[1])
72
+ const end = m[2] ? Number(m[2]) : st.size - 1
73
+ res.writeHead(206, {
74
+ ...base,
75
+ "Content-Range": `bytes ${start}-${end}/${st.size}`,
76
+ "Content-Length": end - start + 1,
77
+ })
78
+ createReadStream(path, { start, end }).pipe(res)
79
+ } else {
80
+ res.writeHead(200, { ...base, "Content-Length": st.size })
81
+ createReadStream(path).pipe(res)
82
+ }
83
+ })
84
+
85
+ return new Promise<RangeServer>((resolve, reject) => {
86
+ server.once("error", reject)
87
+ server.listen(port, () => {
88
+ report?.(`range server: ${dir} on http://localhost:${port}`)
89
+ resolve({ dir, port, server, close: () => server.close() })
55
90
  })
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}`))
91
+ })
92
+ }