ops: track tif_polygons.ts orphan import
entity.ts imports findTifDistrict from ./tif_polygons.js but the source file was never committed — only present in the working tree. Adding it so a fresh clone compiles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9a408e4c4
commit
a05174d2fa
178
mcp-server/tif_polygons.ts
Normal file
178
mcp-server/tif_polygons.ts
Normal file
@ -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<TifFeature[]> {
|
||||||
|
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<TifMatch | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user