diff --git a/mcp-server/tif_polygons.ts b/mcp-server/tif_polygons.ts new file mode 100644 index 0000000..f1f5e4b --- /dev/null +++ b/mcp-server/tif_polygons.ts @@ -0,0 +1,178 @@ +// TIF (Tax Increment Financing) district point-in-polygon lookup. +// Given a property's lat/long, returns which Chicago TIF district (if +// any) contains it. TIF districts are public-subsidy zones — a property +// inside one is receiving city tax-increment funding for its build. +// Strong "this project has financial backing" signal for the Project Index. +// +// Data: data/_entity_cache/tif_districts.geojson (Chicago Open Data +// dataset eejr-xtfb, 100 active districts, 3.2MB). Refresh by re-running +// `curl ... eejr-xtfb.geojson > tif_districts.geojson` — districts +// change rarely (only when city council approves new ones or repeals). +// +// Algorithm: classic ray-casting. For each MultiPolygon's outer ring, +// count edge crossings of an east-going horizontal ray from the point. +// Odd crossings = inside. Holes (inner rings) flip the parity. Library- +// free; correct for arbitrary polygons including the irregular Chicago +// shapes which often have many small detours. + +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const TIF_GEOJSON = join("/home/profit/lakehouse/data/_entity_cache", "tif_districts.geojson"); + +type LngLat = [number, number]; // GeoJSON convention: [longitude, latitude] +type Ring = LngLat[]; +type Polygon = Ring[]; // outer ring + optional inner rings (holes) +type MultiPolygon = Polygon[]; + +type TifFeature = { + name: string; + trim_name?: string; + ref?: string; + approval_date?: string; + expiration?: string; + type?: string; // T-1xx etc. + comm_area?: string; + wards?: string; + // Bounding box for quick reject + bbox: { minLon: number; minLat: number; maxLon: number; maxLat: number }; + geometry: MultiPolygon; +}; + +let tifIdx: TifFeature[] | null = null; + +function bboxOfMultiPolygon(mp: MultiPolygon): TifFeature["bbox"] { + let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity; + for (const poly of mp) { + for (const ring of poly) { + for (const [lon, lat] of ring) { + if (lon < minLon) minLon = lon; + if (lat < minLat) minLat = lat; + if (lon > maxLon) maxLon = lon; + if (lat > maxLat) maxLat = lat; + } + } + } + return { minLon, minLat, maxLon, maxLat }; +} + +async function ensureLoaded(): Promise { + if (tifIdx) return tifIdx; + if (!existsSync(TIF_GEOJSON)) { + tifIdx = []; + return tifIdx; + } + try { + const raw = JSON.parse(await readFile(TIF_GEOJSON, "utf-8")); + const out: TifFeature[] = []; + for (const f of raw.features || []) { + const geom = f.geometry; + if (!geom) continue; + // Normalize Polygon → MultiPolygon for uniform iteration + let mp: MultiPolygon; + if (geom.type === "MultiPolygon") { + mp = geom.coordinates; + } else if (geom.type === "Polygon") { + mp = [geom.coordinates]; + } else { + continue; + } + const props = f.properties || {}; + out.push({ + name: props.name || "Unknown TIF", + trim_name: props.name_trim, + ref: props.ref, + approval_date: props.approval_d, + expiration: props.expiration, + type: props.type, + comm_area: props.comm_area, + wards: props.wards, + bbox: bboxOfMultiPolygon(mp), + geometry: mp, + }); + } + tifIdx = out; + return tifIdx; + } catch (e) { + console.warn("[tif] load failed:", (e as Error).message); + tifIdx = []; + return tifIdx; + } +} + +// Ray-casting point-in-polygon (single ring). Returns true if (lon, lat) +// is strictly inside the ring. Edge cases (point exactly on a vertex) +// resolve by half-open interval convention; for our use case (Chicago +// boundary precision is ~1m, sites are point queries) this is fine. +function pointInRing(lon: number, lat: number, ring: Ring): boolean { + let inside = false; + const n = ring.length; + for (let i = 0, j = n - 1; i < n; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + const intersect = + yi > lat !== yj > lat && + lon < ((xj - xi) * (lat - yi)) / (yj - yi + 0) + xi; + if (intersect) inside = !inside; + } + return inside; +} + +// Polygon = outer ring + holes. Inside outer AND not inside any hole. +function pointInPolygon(lon: number, lat: number, polygon: Polygon): boolean { + if (polygon.length === 0) return false; + if (!pointInRing(lon, lat, polygon[0])) return false; + for (let i = 1; i < polygon.length; i++) { + if (pointInRing(lon, lat, polygon[i])) return false; + } + return true; +} + +export type TifMatch = { + name: string; + ref?: string; + approval_date?: string; + expiration?: string; + comm_area?: string; + wards?: string; +}; + +export async function findTifDistrict( + longitude: number | string | undefined, + latitude: number | string | undefined, +): Promise { + const lon = typeof longitude === "string" ? parseFloat(longitude) : longitude; + const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude; + if (!lon || !lat || isNaN(lon) || isNaN(lat)) return null; + const idx = await ensureLoaded(); + if (idx.length === 0) return null; + for (const f of idx) { + // Bbox reject — cheap O(1) skip for the 99% of districts that + // can't possibly contain the point. + const b = f.bbox; + if (lon < b.minLon || lon > b.maxLon || lat < b.minLat || lat > b.maxLat) continue; + // Full point-in-polygon for any polygon in this MultiPolygon + for (const poly of f.geometry) { + if (pointInPolygon(lon, lat, poly)) { + return { + name: f.name, + ref: f.ref, + approval_date: f.approval_date, + expiration: f.expiration, + comm_area: f.comm_area, + wards: f.wards, + }; + } + } + } + return null; +} + +export async function getTifIndexStats(): Promise<{ + total: number; + loaded: boolean; +}> { + const idx = await ensureLoaded(); + return { total: idx.length, loaded: idx.length > 0 }; +}