/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * Race-by-dot-density builder — the Cooper Center "Racial Dot Map" recipe, from the TIGER DB the * `mailwoman tiger` CLI produces. * * Reads `tabblock20 ⋈ pl_block` (block geometry + Census 2020 P.L. 94-171 table P2 counts) and * scatters one dot per `per` people uniformly at random inside each block, tagged with its * race/ethnicity category. Output is NDJSON (one GeoJSON Point Feature per line, with a * `tippecanoe` layer hint) ready for `tippecanoe -o race-dots.pmtiles`. * * Point-in-polygon uses `@turf/boolean-contains` (ships with `@mailwoman/tiger`). The dot is a * _representation_, not a record: a random position inside the block it belongs to, standing in * for `per` real people of that category. It says nothing about any individual address. * * Build the input DB first: `mailwoman tiger fetch --state 06 --county 059 --out tiger-oc.db` then * `mailwoman tiger redistricting --state 06 --county 059 --out tiger-oc.db`. * * Run: `mailwoman tiger race-dots --db tiger-oc.db --per 10 --out /tmp/race-dots.ndjson` */ import { createWriteStream } from "node:fs" import { DatabaseSync } from "node:sqlite" import { tryParsingJSON } from "@mailwoman/core/objects" import { dataRootPath, tempRootPath } from "@mailwoman/core/utils" /** * Attempts to place a dot inside its polygon by rejection sampling before giving up on it. */ const MAX_PLACEMENT_TRIES = 60 /** * Options for {@linkcode raceDots}. */ export interface RaceDotsOptions { /** * TIGER SQLite DB (`tabblock20` ⋈ `pl_block`). Default `$MAILWOMAN_DATA_ROOT/tiger/tiger-oc.db`. */ db?: string /** * Output NDJSON path. Default `/tmp/race-dots.ndjson`. */ out?: string /** * People represented by one dot. Default 10. */ per?: number /** * Tippecanoe layer name. Default `dots`. */ layer?: string } /** * Result of {@linkcode raceDots}. */ export interface RaceDotsResult { outPath: string dots: number skipped: number blocks: number /** * Dots emitted per P2 category. */ totals: Record } /** * The eight P2 categories (columns in pl_block) that partition each block's population. */ const CATEGORIES = ["hispanic", "white", "black", "asian", "aian", "nhpi", "other", "multi"] as const type Ring = number[][] type PolygonCoords = Ring[] function bbox(rings: PolygonCoords): [number, number, number, number] { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const [x, y] of rings[0]!) { if (x! < minX) { minX = x! } if (x! > maxX) { maxX = x! } if (y! < minY) { minY = y! } if (y! > maxY) { maxY = y! } } return [minX, minY, maxX, maxY] } /** * Race-by-dot-density NDJSON builder — see the module doc. */ export async function raceDots( options: RaceDotsOptions = {}, report?: (line: string) => void ): Promise { const DB = options.db || dataRootPath("tiger", "tiger-oc.db") const OUT = options.out || tempRootPath("race-dots.ndjson") const PER = options.per ?? 10 // people represented by one dot const LAYER = options.layer || "dots" // Heavy dep, lazy-imported so loading the tools barrel stays cheap. const { default: booleanContains } = await import("@turf/boolean-contains") // A block geometry is one or more polygons. Pick a sub-polygon weighted by bbox area, then // rejection-sample inside it with a turf containment test (handles holes + winding correctly). function randomPointIn(polys: PolygonCoords[], areas: number[], totalArea: number): [number, number] | null { let r = Math.random() * totalArea let pick = 0 while (pick < polys.length - 1 && (r -= areas[pick]!) > 0) { pick++ } const poly = polys[pick]! const polyFeature = { type: "Feature" as const, geometry: { type: "Polygon" as const, coordinates: poly }, properties: {}, } const [minX, minY, maxX, maxY] = bbox(poly) for (let tries = 0; tries < MAX_PLACEMENT_TRIES; tries++) { const x = minX + Math.random() * (maxX - minX) const y = minY + Math.random() * (maxY - minY) const pt = { type: "Feature" as const, geometry: { type: "Point" as const, coordinates: [x, y] }, properties: {} } if (booleanContains(polyFeature, pt)) return [x, y] } return null } const db = new DatabaseSync(DB, { readOnly: true }) const rows = db .prepare( `SELECT b.geometry AS geometry, ${CATEGORIES.map((c) => `p.${c} AS ${c}`).join(", ")} FROM tabblock20 b JOIN pl_block p ON b.GEOID = p.GEOID WHERE p.pop_total > 0` ) .all() as Array<{ geometry: string } & Record<(typeof CATEGORIES)[number], number>> db.close() const out = createWriteStream(OUT) const totals = new Map() let dots = 0, skipped = 0 for (const row of rows) { const geom = tryParsingJSON<{ type: string; coordinates: unknown }>(row.geometry) if (!geom) continue const polys: PolygonCoords[] = geom.type === "Polygon" ? [geom.coordinates as PolygonCoords] : (geom.coordinates as PolygonCoords[]) const areas = polys.map((p) => { const [a, b, c, d] = bbox(p) return Math.max((c - a) * (d - b), 1e-12) }) const totalArea = areas.reduce((s, a) => s + a, 0) for (const cat of CATEGORIES) { const people = row[cat] if (people <= 0) continue const exact = people / PER const n = Math.floor(exact) + (Math.random() < exact - Math.floor(exact) ? 1 : 0) for (let k = 0; k < n; k++) { const pt = randomPointIn(polys, areas, totalArea) if (!pt) { skipped++ continue } out.write( JSON.stringify({ type: "Feature", tippecanoe: { layer: LAYER }, properties: { cat }, geometry: { type: "Point", coordinates: [Math.round(pt[0] * 1e5) / 1e5, Math.round(pt[1] * 1e5) / 1e5] }, }) + "\n" ) dots++ totals.set(cat, (totals.get(cat) ?? 0) + 1) } } } out.end() await new Promise((resolve) => { out.on("finish", () => resolve()) }) report?.(`[done] ${dots} dots from ${rows.length} blocks (1 dot ≈ ${PER} people); ${skipped} skipped`) for (const [cat, n] of [...totals.entries()].toSorted((a, b) => b[1] - a[1])) { report?.(` ${n.toString().padStart(7)} ${cat}`) } return { outPath: OUT, dots, skipped, blocks: rows.length, totals: Object.fromEntries(totals) } }