Complied Map
How /map works: the filter model, the query service, the snapshot pipeline and the tenant boundary. For engineers debugging a map request, adding a filter, or operating map-service/. The user-facing behaviour is in violations, buildings and the map.
Overview
The map has one filter object, held in the URL. That filter goes to one query service (map-service/, DuckDB over a nightly Parquet copy of the open city records). One response carries the totals, the ranked list and the pins, all computed from the same filtered set, so they cannot disagree.
URL facets -> MapQueryFilter -> POST /map/query -> { totals, in_view, ranked, pins }
If the three ever disagree, something is wrong in the service. Do not repair it by filtering in the browser.
Postgres prepares, DuckDB answers:
- Postgres keeps the vocabulary and the mapping: the taxonomy tables,
map_event_sources(one row per source-id prefix: its department, fallback category, and which columns are its order code and agency status) and the viewmap_event_sourcethat reads it, the one place apublic_eventsrow gets its department and category. - The exporter reads that view plus
buildingsand the taxonomy and writes Parquet. - The service holds a snapshot in memory and answers every request from it.
- The browser never talks to the service about tenant data without the user's own token.
Why DuckDB
The first design ran the query in Postgres: a slim copy of open events, one query function, a result cache. Timed on a local database inflated to prod scale (38.7M public_events rows, 10M open, 1.1M buildings; row counts synthetic, timings meaningful):
| Postgres | DuckDB over Parquet | |
|---|---|---|
| Worst cold question ("every building") | 13.9 s (fails the 8 s authenticated statement timeout) | about 0.2 s |
| Worst repeat question | 2.4 s (cached) | about 0.2 s, no cache |
| Narrow lens (616 overdue in Brooklyn) | 0.7 s cold | 24-29 ms |
| Nightly refresh | about 2 min incremental, 16 min full | about 80 s for a full export of all history |
| Needs a result cache | yes | no |
| History over all 38.7M rows | not possible in that design | 250-530 ms |
Both engines returned identical building counts on every lens. The cost is one small extra service. The benchmark scripts in scripts/map-bench/ time the retired Postgres path and are historical; see scripts/AGENTS.md.
The filter model
| Axis | Filter fields | Semantics |
|---|---|---|
| What | depts, categories, orders | ORed with each other. Kept in one normal form by normalizeWhat (whatTree.ts, shared by the menus and smart search): children of a chosen parent are dropped; a department with every category chosen folds into the department. Orders never fold into their category, because the category also holds records with no order code. Orders are written dept:code. |
| Status | statuses, agencyStatuses | ORed with each other, ANDed with What. Agency statuses are dept:NAME. |
| Where | boroughs, yearFrom/yearTo, minUnits/maxUnits | Building facts, ANDed. |
| When | correctByFrom/To, hearingFrom/To, issuedFrom/To (MapDateField, DATE_SQL in sql.ts) | Record date bounds, YYYY-MM-DD, both ends included; a record without the date never matches a bound on it (only HPD orders have a correct-by date, only OATH summonses a hearing date). ANDed with each other, with What and with Status; they narrow both menus' counts (compileFacetCounts) and the opened building's matching. A malformed date is a 400. A snapshot exported before events.issued_date refuses an issued bound with a reason. |
| Has | features (BUILDING_FEATURES in shared/buildingFeatures.ts) | Facts from the open-data record tables: elevator, boiler, cooling tower, water tank, facade Unsafe / SWARMP, HPD registration expired, Energy Star score under 50, active DOB permit, temporary CO only, tax abatement, tax exemption, on the CONH list, under HPD jurisdiction. ANDed with each other and with Where. The "Building has" filter, each option with its count under the filter (/map/query feature_counts); has= in the URL. |
| Whose | scope: all, tracked, prospects | Needs the tenant overlay for tracked and prospects. |
| Owed | Obligation facets, sent as buildingIds | Evaluated in the browser; forces scope = tracked. |
An empty What, Status and When means every building, still narrowed by Where, scope and buildingIds.
Departments come from the map_departments table: HPD (violations by order number, complaints, housing-court cases, vacate orders, AEP, emergency repair charges; its 311 layer went with migration 20261014090000, since HPD 311 requests are HPD complaints), DOB (violations by code, complaints, DOB NOW safety violations filed under the boiler, elevator, facade and energy categories by program), ECB / OATH (summonses by issuing agency), FDNY (building vacate orders), DOHMH (rodent jobs whose latest inspection failed, by result) and DOT (sidewalk violations not yet dismissed), the last two from migration 20261011100000. The departments with a feed behind them are listed in mapAgencies.ts (INGESTED_DEPARTMENTS); the "Everything open" preset selects exactly those. DSNY is seeded with available = false and shown disabled; what exists for it is its OATH summonses under ECB / OATH. Categories and orders live in map_categories and map_category_orders, not in TypeScript. An HPD order the seed does not name falls into its department's catch-all. GET /map/taxonomy serves the whole tree with each option's open-record count in the loaded snapshot, so the menus never offer a choice with nothing behind it.
Statuses
| Key | Meaning | Source |
|---|---|---|
overdue | The app's one overdue rule (domain/src/violations/deadlineRule.ts): HPD correct-by date passed, and no certification on file unless HPD rejected it (false or invalid) | HPD newcorrectbydate / originalcorrectbydate, hpd_certified_date and status_detail, evaluated on the New York date of the request |
in_window | Has a correct-by date that has not passed | public |
postponed | HPD granted a certification postponement | status_detail = OPEN_POSTPONEMENT_GRANTED, or OPEN_POSTPONEMENT_GRANTED_OVERDUE on rows synced before the ingest stopped stamping the suffix |
hearing | An OATH hearing is on the calendar | ECB / OATH hearing date on or after today |
owed | A city-issued balance is outstanding | ECB / OATH balance_due, never a figure Complied derives |
contested | The tenant filed a contest | Complied's own record: the latest non-superseded project_order_decisions row with path = 'contest' |
| agency names | HPD currentstatus, ECB/OATH hearing_status, DOB violation category | public |
The filter registry. Every browser-side filter is one MapFilterDef in MAP_FILTERS (mapFilterRegistry.ts): its id, label, one-line help, group (What's wrong, How urgent, Which buildings, Our work), how its options combine, the URL params and MapFacets fields it owns (decode / encode), its part of MapQueryFilter (toQuery, and fromQuery back), its chip text and its clause in the summary sentence, where its option counts come from (/map/facets, /map/query's feature_counts, the borough totals, the portfolio's obligation facts), its editor kind (tree, multi, enum, range, date range, boolean) and the permission it needs (the three obligation filters need view_obligations). The filter bar, the chips, the sentence, the URL codec, the query and saved views all walk the list; none names a filter. Rules between filters are the registry's too: an obligation filter's constrain holds scope = tracked, and the scope chip shows a lock (lockedBy) while it does.
Presets (MAP_PRESETS in mapFacets.ts) are what the lenses were: named starting points that fill in ordinary filters, not a hidden layer under them. Choosing one replaces the filter with its values and keeps the borough. The default view, what /map with no filter params shows, is the open-lead preset, drawn as a visible "Violation type" chip; Clear all is EMPTY_FACETS (every building). Two flags come from the preset list: child at risk (an open order 618, the only public building-level sign a child tested positive) and records overdue (RPO past deadline). The canvas palette is read off the filter (rampFor): lead records → the lead ramp, other records → the all-agency ramp, no records with the year capped before 1978 → pre-1978, our buildings → lead, else density.
Obligation facets. The obligation filters (annual notice, tenant response, visual inspection, children under 6, turnover, LL31 and others) are tenant-scoped facts about apartments. The browser evaluates them with the same needsForUnit() that /deadlines uses, so there is one ladder (mapObligations.ts). useObligationFilter holds the portfolio facts and the match. The page sends the portfolio-wide list of matching tracked buildings as buildingIds, so the server narrows totals, list and pins by it. While the facts load, the query waits. Tracked buildings with no apartments on file cannot match, and the rail says so.
URL vocabulary. View state lives in the query string, so a view is a link and a saved view is a stored query string (saved views are v: 2).
dept,catare comma-separated;order,agencyStatusare|-separated because HPD status names contain commas.- Statuses are
state, notstatus. On/violations,statusmeans OPEN / CLOSED / DISMISSED; every map status is a state of an OPEN record.mapLinkFromViolations()(mapFacets.ts) translates a violations link, dropping filters with no map equivalent. It maps agencies to departments and categories throughmapAgencies.ts, the one table both surfaces read. - Every change is written relative to the default view (
encodeFacets), so a link carries only what differs; a list the default fills and that has been cleared is writtennone(Clear all is?cat=none). - Older links name a
lens=.parseFacetsreads it as the base the other params override, exactly as before; the next change rewrites the link without it.mapUrlFixtures.jsonpins 74 such links (every lens,none, every facet, smart-search and violations-link shapes) to the exact filter they sent before the registry, andmapFilterRegistry.test.tschecks each, its rewritten form and its saved-view form. - The date bounds are
dueFrom/dueTo,hearingFrom/hearingToandissuedFrom/issuedTo. - A selected building is
?b=<buildingId>.
The tenant line
Three parts of the filter are tenant-specific: scope, contested and buildingIds. map-service holds no service-role key and no JWT secret.
- The browser sends the user's access token.
- The service calls PostgREST
map_tenant_overlay()with that same token. - The function is
SECURITY INVOKERand matchestenant_id = current_tenant_id()explicitly, not RLS alone, because a platform operator's broad read policy would otherwise turn "already in Complied" into "in anybody's Complied". Impersonation works becausecurrent_tenant_id()honours it. - The result lands in small in-memory tables keyed by that tenant id, and every query filters on it. Overlay results are cached per token for
MAP_OVERLAY_TTL_SECONDS(60 s). - There is no shared result cache, so there is nothing for one tenant's answer to leak into.
If PostgREST refuses the token, the map refuses the request. Never accept tenant_id from the request body, fetch the overlay with a service key, or cache a public result that already folded in tenant predicates. Tested in snapshot.test.ts ("the tenant line") and in db-tests/tests/leakage.test.ts ("map_tenant_overlay").
Request path
Map load or filter change
- The user changes a chip or lands on a URL;
CompliedMapPagedecodes facets from the query string. - If obligation facets are set,
useObligationFilterwaits for portfolio facts and derivesbuildingIds. facetsToFilterbuilds a plainMapQueryFilter;useMapQuerycallsqueryMap({ filter, viewport, zoom, sort })inmapQuery.ts, which POSTs it with the session JWT.- The service resolves the tenant overlay, compiles SQL once (
compileMapQuery), runs it against the snapshot and returns one JSON document. - The page paints totals, rail and pins from that one document.
useMapQuerywithholds the counts until the answer is to the current filter; the previous answer's pins stay drawn meanwhile.
Viewport affects only in_view, ranked and the pins. Totals cover the whole filtered set. Pins are individual buildings when the view holds at most 3,000 matches (MAX_POINTS), otherwise grid cells (centroid of matching buildings per square, about 1/8 of a map tile, snapped to a fixed world grid). From zoom 14.5 (CLOSE_ZOOM) the cap rises to 15,000 (MAX_POINTS_CLOSE), so a dense neighbourhood is always individual buildings at street level and never mixes grid cells with lit buildings. The canvas draws cells as flat glows on the ground under a gently tilted camera (cellLayers in mapStyle.ts). Individual buildings are dots that hand over to the buildings themselves: from zoom 15, where Mapbox Standard draws footprints, the basemap's 3D building for each pin is lit (Standard's buildings featureset, select state rose for overdue, highlight amber for open) and that pin's dot is hidden (dotFade). Which building is a pin's is decided on the ground, not on screen (buildingMatch.ts): the rendered footprint that contains the pin's point, else the nearest within 25 m, with pins taken in a fixed order (overdue, then records, then id) so the same building wins at every zoom and tilt; a pin whose building tile is still loading keeps last pass's building instead of blinking back to a dot. Dots for buildings with nothing open fade out from 13.5. A pin with no footprint nearby keeps its dot. Clicking a lit building opens its pin. The lighting is client-side and per view: lightBuildings in useBuildingLighting.ts makes one rendered-features query per pass and re-runs when the map goes idle, and every 300 ms while the user moves the camera (not during programmatic flights). Standard's 3D tree and landmark models are off (show3dTrees, show3dLandmarks): their shaders compiled mid-flight and stalled the street view dive on integrated GPUs; buildings, landmarks included, stay 3D as plain extrusions. The basemap uses Standard's muted faded theme so severity colour is the loudest thing on the canvas. The map frames New York without cropping the region: everything outside the five boroughs sits under a veil in the page's background colour, the city's edge has a soft brand-green glow and a hairline, and dashed borough borders show citywide and fade out by zoom 13 (cityFocusLayers in mapStyle.ts; the outline in nycBoundary.ts is NYC Open Data's water-included borough boundaries, dissolved and simplified). The camera is held inside NYC_MAX_BOUNDS, a box around the city wide enough that it never touches the edge of the screen. Selecting a building shows its street photo in the popup (Mapillary, facing the building; bird's-eye satellite when none faces it) and a satellite card with a location pin in the corner; the popup photo and its Street view button dive the camera to street level and cross-fade into that photo once it has loaded, with the map shrinking to a corner minimap. From zoom 16, moving the pointer along any street that has photos snaps to the nearest Mapillary photo point (an invisible layer of the photo tiles, within 60 px): a ring on the ground marks where you will stand and a small card shows that photo's thumbnail and date; clicking opens exactly that photo. A one-time tip says so the first time the camera comes that close. The viewer library is preloaded when a photo is hovered or a building selected, the viewer itself is created only once the dive has landed (creating it mid-flight competed with the camera for the main thread), the photo fades in with opacity and scale only, and the map is resized once when the minimap transition ends, so the hand-over stays smooth. ranked holds up to 500 rows in view, ordered server-side, so changing the sort is exact.
The page. /map is full-bleed (usePageFullBleed): the map fills everything the staff shell leaves it, and the controls float over it in frosted glass on the app's surface tokens (floating.ts), so Light, Dark and Eco all follow. The frame is arithmetic in mapLayout.ts: ONE side pane down the RIGHT edge from lg up holds either the ranked list (RAIL_WIDTH, flush, full height; folded to a round button in the corner) or an opened building's pane in its place, and every floating thing is laid out in the part of the map it leaves free (overMap), with the camera padded by the same width when it flies. The search pill sits at the top (one search box with a Find | Ask switch: Find opens a building from an address, Ask turns a sentence into the filter and opens with example questions; and ⋯: all five boroughs, our buildings, zoom to the opened building, the legend, the rings, copy a link), with the filter bar (MapFilterBar.tsx) under it: the summary sentence with Views (saved views from useSavedViews("map") first, with load, delete and "Save current view…", then the presets, in one menu so the bar never grows with them) and Clear all beside it; one dropdown per quick registry entry (Violation type, Urgency, Borough, Building type, Year built), on the bar whether set or not and filled in with its value once set, closed by All filters; and a row of chips for the other filters that are on (click to edit in a popover from lg up, full screen on a phone; × clears it; on a phone each row scrolls sideways). All filters is a two-pane panel: the registry's groups down the left with their active counts (click to scroll; the one at the top lights as you scroll), every filter's editor open on the right with a Clear link (option lists without hints sit two to a row, and the department tree scrolls in its own box), a search box over labels and help, and a footer button saying how many buildings the filter finds. There is no filter drawer. Sort lives in the rail's header and Layers in the bottom row, since neither is a filter. The control capsule (MapControls.tsx) sits top left: zoom in and out, the whole city, and 2D / 3D (?view=2d: pitch 0, north up, no rotation). Along the bottom sit the view switch (MapViewSwitch.tsx) and Layers (MapLayersPanel.tsx: five Mapbox styles from mapStyles.ts, and the rows Our buildings, Legend, Jobs today, Field history, 3D buildings and Satellite card), with the legend bottom right. On a phone the list is a bottom sheet that rests at a peek, half or full (sheetSnap.ts; tap or drag the grabber) and the bottom row rides above it. The page sets --map-inset-right, --map-bar-bottom and --map-inset-bottom on its frame for what Mapbox draws in its own DOM (the credit, mapChrome.css) and the street-view tips. The Layers switches, the style and the pane's dragged width are per-browser preferences (useMapChrome, complied.map.pane-share), never in the URL. Zoomed out below OVERVIEW_ZOOM, each borough is labelled with its count under the filter: useBoroughTotals asks /map/query once per borough with the filter narrowed to it (viewport null, limit 1), so the labels are the service's numbers, never sums of the 500-row list or of border-straddling cells. Hovering a pin shows a hover card (hoverCard.ts): the ranked row's address and facts when the pin is in the list, else the pin's own two counts.
The view switch (mapView.ts, ?view=earth|street|orbit). Map is the Mapbox map. Earth, Street and Orbit are drawn by Google (google/GoogleEarthView.tsx: the Maps JavaScript API's beta maps3d photorealistic <gmp-map-3d>, 3D markers for the current page of the list, flyCameraTo / flyCameraAround, and a StreetViewPanorama at the nearest outdoor panorama within 120 m, turned to face the building) when VITE_GOOGLE_MAPS_API_KEY is set and Google has not refused it this visit. The key also needs the Maps JavaScript API and the Map Tiles API enabled; lib/googleMaps.ts loads the script once, on first use, and gm_authFailure makes the page fall back. Google draws over the free part of the map only, with the Mapbox canvas mounted underneath and hidden, never Google imagery layered on the Mapbox map; the camera is handed over both ways (google/earthCamera.ts, zoom to range). Without Google each view has a Mapbox stand-in, said in one line over the switch: Earth is Standard Satellite in 3D, Street is our Mapillary street-view dive facing the building (leaving it returns to Map), and Orbit is the Mapbox camera circling the building (a hand on the map stops it). Street and Orbit need an opened building.
Crew layers. Jobs today and Field history (in Layers, for view_field_jobs) read this firm's real field visits with their building's coordinates (data/mapCrew.ts: inspections to projects to buildings, tenant-filtered, read for a window of the last 32 days plus every future booking, and paged): today's scheduled visits coloured per assigned inspector (unassigned in red), and visits completed in the last 30 New York days, with a day scrubber (MapHistoryScrubber.tsx) to step back through them. A crew dot opens its building. The pure logic is crewLayer.ts. There is no live crew position: Complied receives no location pings from the field, so the client fork's sample "Live crew" layer is not built.
Opening a building. Selecting a pin, a lit building, a row, a crew dot or an address sets ?b=; useSelectedBuilding calls getMapBuildingDetail, which calls POST /map/building, which uses the same predicates as the query so its matching counts equal the pin. matching has the same breakdown as open (statuses, balance, next dates), departments and orders carry matching_overdue, and building.features lists every "Building has" fact the building holds by the filter's own FEATURE_SQL predicates. buildingPopupSummary (popupSummary.ts) says the building in the filter's terms: the matching records at the level the filter picked (orders, else categories, else departments, largest first, at most three), led by the status the filter asked about, and the building facts and apartment needs that put it on the map. From lg up the building opens straight into its pane (MapBuildingDrawer.tsx) in the list's place (the list stays mounted, hidden, so its scroll survives); on a phone the first tap shows the popup (BuildingPopupCard) and "Open building" opens the pane as a full sheet (panel=building), which lowers to a peek with "Map". The pane holds the building's picture (its front from Google Street View, else a Mapillary photo facing it, else the aerial; see src/lib/buildingPhoto.ts) with a Street View button that dives into it; the address once, with borough, zip and client; the building at a glance (PaneAtAGlance: open, overdue, owed, and only the alerts that need attention, from the snapshot and the HPD registration); Building page and Directions with the rest behind ⋯ (Open in Violations, Zoom here, Copy link, HPD Online, DOB BIS, ZoLa); our projects; then the tabs Overview (why it is on the map, the filter-aware breakdown by agency and type in PaneViolationSummary, then PaneFacts: the building's facts and HPD registration), Violations (the building page's tab, its agency, status and page kept in useLocalParamStore, because those keys are the map's filter in the map's URL) and City records. In the pane both tabs use their compact layout (agency chips and CompactViolationList rows; BuildingRecordsCard without its own card, stacked, and without the HPD registration and contacts the Overview already shows) so they read as part of the pane; Full page gives them the building page's wide layout. It opens at two fifths of the page, is dragged by its left edge (arrow keys too) between PANE_MIN_PX and what leaves the map PANE_MIN_MAP_PX, and Full page spreads it over everything. "Back to the list" or Escape closes the building and flies the camera back to where it was before it opened. Clicking another pin swaps the building in place. Facts the snapshot does not carry (BBL, zip, HPD ID, client, projects) come from Postgres via getMapBuildingExtras. Every URL write starts from the address bar rather than the render's searchParams: React Router navigates in a transition, a camera flight's frames can hold that transition uncommitted, and a write built on the stale copy once dropped the opened building.
Smart search. POST /map/search { q } turns one sentence into a filter. The model sees a fixed vocabulary (including the "Building has" facts, BUILDING_FEATURES) plus the snapshot's taxonomy and answers in a JSON schema; sanitize drops anything outside the vocabulary; the browser applies the result through facetsFromSearch, the same codec as manual filters, never raw model output. Without MAP_SEARCH_PROVIDER the route answers 501.
HTTP API
Every route except /health requires Authorization: Bearer <Supabase access token>.
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /map/query | { filter, viewport, zoom, sort, limit? } | totals, in_view, ranked, pins, sync_at, as_of, build_ms |
| POST | /map/building | { filter, buildingId } | Building facts and matching / open counts by department and order |
| GET | /map/taxonomy | Departments, categories, orders and status counts for the loaded snapshot, and features: buildings citywide with each "Building has" fact (null for a snapshot missing any flag column a feature reads, FEATURE_COLUMNS) | |
| POST | /map/search | { q } | Sanitized facets plus summary, unmatched, empty |
| GET | /health | { ok, snapshot }, no auth, no tenant data |
Snapshot pipeline
- Every public-data sync ends with
mark_map_stale()(the CLI rebuildsbuilding_flagswithrefresh_building_flags()first), which stampsmap_data_version.changed_at(theingest/CLI calls it). - The exporter (
exportIfStale, orMAP_EXPORT_WATCHin a long-running process) exports when the stamp is newer than the current snapshot and has been quiet forMAP_EXPORT_QUIET_MINUTES(default 10). Cron fires eight feeds together, so one night costs one export. - Export reads
map_event_source,buildings(with everybuilding_flagsfact column left-joined on, read from the table's own columns, so a new flag needs no exporter change) and the taxonomy and writes Parquet undersnapshots/<id>/, then writeslatest.jsonlast, so a reader never sees half a snapshot. At prod scale: about 2 minutes, about 235 MB for 10M open rows. - The server polls
latest.json, loads the new snapshot into a second in-memory DuckDB, swaps, and closes the old one when its in-flight requests finish. Memory briefly holds two copies (a 2 GB machine is enough).
Storage is a local directory or s3://bucket/prefix through DuckDB's httpfs, so AWS S3, Supabase Storage's S3 endpoint and R2 all work. httpfs cannot delete objects, so old snapshots expire by bucket lifecycle rule.
The canvas
What the browser draws over /map/query's answer (CompliedMapView.tsx). Nothing on it is a second, unfiltered picture: every dot, cell, lit building and borough count is the same filtered answer.
- The basemap. Mapbox Standard in its full-colour grade with an Apple-Maps-like palette (
basemapLook.ts), lit by day in every appearance (the chrome goes dark, the city does not). Place labels anchored outside the city limits are hidden through theplace-labels/poifeaturesets'hidestate, so is "New York" itself, and the five borough names while the outline map names them. A little terrain under the city from 640 px wide. - Opening. A fresh map flies in from high over the region onto the whole city, then frames our tracked buildings (
ownBuildingsBounds) unless someone has touched the map, opened a building or arrived with a camera in the link. Labels fade in once it lands. - The floor. "All the way out" is measured, not fixed:
cityFrame.tssolves the zoom at which the five boroughs fill what the chrome leaves free under the tilted camera (58°, turned -14°), and that becomesminZoom;roamBoxis the pan box. Zooming out to the floor re-centres the city. - Zoomed out. The outline map (
OverviewMap.tsx): each borough a soft fill deepening with its share of what is open under the filter (one narrowed/map/queryper borough,useBoroughTotals), a quiet outline, its name and count as map text, a glass card on hover (open, overdue, tracked, the most-open building), a click flies to it. It fades out over the 0.6 → 1.6 zoom levels above the floor. Below zoom 12.5 every building is a small plain dot, ours green and clustered with a count below zoom 11. - Closer. From 12.5 to 14.5 the dots take their severity colour and size and the list's halos fade in; our green dot opens into its ring. From zoom 15 the answer's buildings stand up as lit shapes in their status colour — their real outlines from NYC Building Footprints by BIN (
useFootprintGlow, fetched from NYC Open Data in batches of 40, cached for the session, a failure retried on the next view). A building with no BIN or no outline lights the basemap's own 3D building instead (useBuildingLighting). A lit building's dot stands down. - A building opened. The map flies itself (no camera request from the page): a high arc landing over the street in front of the building, looking at its front (
buildingCamera: the address point says which side the street is, the outline's centre is the target). It waits up to 1.2 s for the outline. Orbit (without Google) circles it once landed; 2D / 3D flies again; a right-drag turns the camera only around an opened building. - When the basemap fails (no WebGL, a refused token, a style that never arrives in 25 s) the canvas says so in words with "Reload the map" (
basemapHealth.ts), never a blank rectangle.
File map
Frontend
| Path | Role |
|---|---|
src/pages/CompliedMapPage.tsx | Owns filter state and URL sync; wires the hooks below to the canvas and the floating chrome, and keeps the controls and the camera clear of the rail and pane (frame insets, camera padding) |
useMapQuery.ts | The one /map/query, keyed on what the question is (filter, bounds, whole zoom level, sort). Counts are withheld until the answer is to the current filter; the last answer's pins stay drawn meanwhile |
useSelectedBuilding.ts | The opened building (?b=): its details under the current filter and its extras |
useObligationFilter.ts | Browser-side obligation facets, resolved to the building ids the query sends |
useMapChrome.ts | Remembered display preferences, per viewer in this browser: the rail open or folded, and the Layers switches |
floating.ts, mapChrome.css, useElementSize.ts | The frosted-glass look of everything floating over the map; Mapbox's corner controls and the popup frame kept clear of it; measuring the toolbar and the phone's sheet for the insets |
MapToolbar.tsx, MapLayersPanel.tsx, mapStyles.ts | The search pill (address box, Filter, ⋯, applied-filter chips); the Layers button (map style, rings, legend, crew layers, 3D buildings, satellite card); the five Mapbox styles |
mapLayout.ts, sheetSnap.ts | Pure: the one side pane, its widths and the insets the chrome and the camera keep to; the phone sheet's three levels |
MapControls.tsx, MapViewSwitch.tsx, mapView.ts | The control capsule (zoom, whole city, 2D / 3D); the view switch and its plan (Google or the Mapbox stand-ins) |
google/, src/lib/googleMaps.ts | Google's Earth / Street / Orbit over the free map, the camera hand-over, the minimal API types; the script loader |
crewLayer.ts, MapHistoryScrubber.tsx, src/data/mapCrew.ts | Jobs today and Field history from real field visits; the day scrubber |
useBoroughTotals.ts, hoverCard.ts | Each borough's count under the filter, for the zoomed-out labels; the hover card's words |
mapFilterRegistry.ts | MapFacets, EMPTY_FACETS and MAP_FILTERS, one entry per filter: words, group, URL params, query mapping, chip, sentence clause, counts, permission, editor kind |
mapFacets.ts | Presets (MAP_PRESETS), the default view, the URL as a whole (parseFacets with legacy lens=, encodeFacets), rampFor, facetsToFilter, facetsFromSearch, mapLinkFromViolations |
MapFilterBar.tsx, MapFilterEditor.tsx, MapRecordFilters.tsx, MapSmartSearch.tsx | The filter bar (sentence, Views, Clear all, quick filters, chips, All filters); one editor per registry editor kind; the department / category / order tree; useSmartSearch. The pill's search box is the shared BuildingSearch (Postgres search_buildings) with its Find |
MapLegend.tsx | The key and the tracked-rings toggle, a glass card over the canvas |
MapResultsRail.tsx, MapBuildingDetails.tsx, BuildingPhoto.tsx | Ranked list (a full-height column flush right from lg, a three-level bottom sheet on a phone) with its sort, three counts and photo rows that open the pane; the Overview tab's breakdown of what is open (PaneViolationSummary); building photo |
buildingDisplay.ts | Pure formatting and links for an opened building |
popupSummary.ts | Pure: what the popup says about a building under the map's filter |
MapBuildingDrawer.tsx | The building pane in the list's place, each fact once: the Street View picture, the address, the building at a glance, Building page / Directions / ⋯, our projects, then the Overview / Violations / City records tabs; the resize handle |
CompliedMapView.tsx | Mapbox canvas drawing the query's pins only (no second unfiltered layer); the cell colour ramp tops out at the 90th percentile of the current answer. The fly-in, the measured zoom floor, label tidying, click routing, the flight onto an opened building, the orbit (see "The canvas" below) |
basemapLook.ts, basemapHealth.ts | Standard's basemap config (the Apple-like palette, day light, labels after the fly-in); whether the basemap itself drew, and the words when it did not |
mapCamera.ts, cityFrame.ts | Pure: the resting camera, the building camera (over the street, facing the front), the orbit, the our-buildings box; the tilted whole-city fit and the roam box |
mapGeoJson.ts, mapStyle.ts | The GeoJSON sources; layer styles and severity palettes: zoomed-out markers, cells, rings and clusters, the list's halos, the lit footprints (footprintLayers) |
boroughShapes.ts, data/ | NYC Open Data's borough boundaries (gthc-hcne, shoreline) and city limits (wh2p-dxnf, water included), kept as data files; boroughAt, insideCity, each borough's box and label point |
OverviewMap.tsx, overviewStyle.ts | The zoomed-out outline map: borough tints, outlines, names with counts, the hover card; its layers and zoom fade |
useFootprintGlow.ts, src/data/buildingFootprints.ts | The answer's buildings as lit outlines from GLOW_ZOOM: NYC Building Footprints (5zhs-2jue) by BIN, live from NYC Open Data, batched and cached for the session; hover and selection as feature state |
useBuildingLighting.ts | The fallback where a building has no footprint of its own: lights the basemap's 3D building for its pin (lightBuildings), one rendered-features query per pass |
crew/ | The crew modes (?mode=jobs|route|history): CrewMapView, the mode pill OpsModeMenu, pure crewModes.ts |
PaneStreetView.tsx, BuildingPaneOverview.tsx | The building pane's picture (Google's interactive Street View facing the building, else the still); its at-a-glance strip, the Overview tab's facts and registration, and "Our projects here" (city records read once by useCityRecords.ts) |
buildingMatch.ts | Pure: which rendered footprint each pin lights, decided on the ground |
useStreetView.ts | Street view's dive, minimap, hover snapping and tip; its timers are cleared on unmount |
StreetViewPanel.tsx, StreetViewOverlays.tsx, streetViewerLoader.ts | The Mapillary viewer, created once the dive lands; the ring, ripple and walker markers on the map; the viewer library's shared, preloadable import |
BuildingPopupCard.tsx | The satellite card beside the popup's street photo |
src/lib/mapAgencies.ts | The one table mapping violations-page agency values to map departments and categories; INGESTED_DEPARTMENTS, agenciesForDept, mapWhatForAgencies |
src/lib/mapFilter.ts | MapQueryFilter, status vocabulary, What-tree collapse, summaries |
src/lib/mapVocabulary.ts | Boroughs, unit bands and the lead-paint cutoff; constants with no Supabase import |
src/lib/mapObligations.ts | Obligation facets evaluated with needsForUnit() |
src/lib/mapStreetView.ts, src/lib/mapbox.ts | Mapillary coverage tiles and image lookup; the public Mapbox token |
src/data/mapQuery.ts | All HTTP to the service, the tracked-point overlay, and building extras from Postgres |
map-service (map-service/)
| Path | Role |
|---|---|
src/sql.ts | The only place a filter becomes SQL; also building detail and taxonomy. Values go through lit() or are validated numbers and UUIDs. FEATURE_SQL holds each "Building has" fact's column and predicate |
src/snapshot.ts, src/overlay.ts | One loaded DuckDB with hot swap; JWT to map_tenant_overlay() |
src/exporter.ts, src/storage.ts, src/cli-export.ts | Postgres to Parquet and the staleness rule (the building_flags columns are read from the table itself); local or S3 store; npm run export |
src/search.ts, searchVocabulary.ts, llm.ts | Smart search; Anthropic Messages or OpenAI-compatible providers over plain fetch |
src/app.ts, server.ts, lambda.ts | Routes with no server attached; node:http plus poll and export timers; Lambda adapter |
src/config.ts, runtime.ts, duckdbHome.ts | Every environment setting; the snapshot load and swap the server and Lambda share; a writable DuckDB home on Lambda |
src/shared/buildingFeatures.ts | BUILDING_FEATURES, the one list of "Building has" facts; BuildingFeature derives from it |
src/shared/contract.ts, whatTree.ts | The wire types; normalizeWhat |
src/shared/ | The wire contract the frontend imports through the @map-shared/* alias; imports nothing outside shared/ |
Postgres
| Migration | Role |
|---|---|
20260924120000_map_query.sql | Taxonomy tables and the map_event_source view |
20260924150000_map_duckdb_service.sql | map_data_version, mark_map_stale(), map_tenant_overlay() |
20260925120000_map_postponed_status_detail.sql | postponed reads status_detail instead of raw_data->>'currentstatusid' |
20261007110000_map_event_source_columns.sql | The view reads the promoted columns, never raw_data (order_code ← agency_order_code, agency_status ← agency_status_text, balance_due ← balance_due); correct_by is set for every open HPD violation, certified or not; adds hpd_certified_date and status_detail for the overdue rule |
20261011100000 | The DOHMH (rodent) and DOT (sidewalk) departments and categories |
20261011130000_map_event_sources.sql | map_event_sources, one row per source, replaces the view's per-source CASE chains; DOB NOW safety (by program) and OATH (by issuing agency) keep a per-row category rule (category_by). Same rows as before |
Local development
npm run dev # repo root: Supabase check, Vite, functions and map-service with export watch
cd map-service && npm install && npm start # service only, :8787
cd map-service && npm run export # take a snapshot now
cd map-service && npm test
The first local snapshot appears a few seconds after start and re-exports a minute after any local sync. Node runs the .ts files directly (type stripping, Node 22.18 or later), so only erasable TypeScript syntax is allowed. npm run dev -- --no-map skips the service. In a dev build the frontend falls back to http://127.0.0.1:8787; a production build has no fallback and an unset VITE_MAP_SERVICE_URL makes the map fail loudly. Environment variables are tabulated in map-service/AGENTS.md. Deployment: deployment.
Where each filter's data comes from
Every filter reads a column of the nightly snapshot except the apartment filters and "Our buildings", which read Complied's own records. The snapshot's columns come from three places in Postgres: the open records (map_event_source, a view over public_events), the buildings (buildings, mostly PLUTO), and the building facts (building_flags, rebuilt by refresh_building_flags() from building_assets, building_filings and building_designations). Dataset ids are NYC Open Data's.
Records (what is open; public_events, status_norm = 'OPEN'):
| Filter | Column | Feed (dataset) |
|---|---|---|
| Violation type: department and category | map_event_sources row per source-id prefix, then map_category_orders | HPD violations (wvxf-dwi5), complaints (ygpa-z7cr), litigation (59kj-x8nc), vacate orders (tb8q-a3ar), AEP (hcir-3275), OMO (mdbu-nrqn), HWO (sbnd-xujn); DOB violations (3h2n-5cm9), complaints (eabe-havv), safety violations (855j-jady); ECB (6bgk-3dad) and OATH hearings (jz4z-kudi); FDNY vacates (n5xc-7jfa); DOHMH rodent inspections (p937-wjvj); DOT sidewalk (6kbp-uz6m) |
| Violation type: order number | agency_order_code, citation_code or event_type, per source (order_code_from) | as above |
| Agency status | agency_status_text or event_type, per source (agency_status_from) | as above |
| Urgency: overdue, in window | due_date (HPD only) against today in New York, unless certified (hpd_certified_date, status_detail) | HPD violations |
| Urgency: postponed | status_detail = OPEN_POSTPONEMENT_GRANTED… | HPD violations |
| Urgency: hearing, money owed | due_date (the hearing), balance_due | ECB / OATH |
| Urgency: contested | project_order_decisions, the tenant's own contests (map_tenant_overlay) | Complied |
| Issued date | issued_date | every feed |
| Correct-by date | due_date (HPD only) | HPD violations |
| Hearing date | due_date (OATH only) | ECB / OATH |
| HPD class, Rent impairing | hpd_class, hpd_rent_impairing | HPD violations |
| Where in the building | unit_label present (apartment) or not (public areas) | HPD violations |
Buildings (buildings; PLUTO is 64uk-42ks, loaded by the identity step, ingest identity):
| Filter | Column | Source |
|---|---|---|
| Borough | borough → normalize_borough_name() | PLUTO |
| Year built | year_built (0 = not recorded, never counted as old) | PLUTO yearbuilt |
| Units | total_units | PLUTO unitstotal |
| Floors | num_floors | PLUTO numfloors |
| Floor area | bldg_area, gross sq ft for the whole tax lot | PLUTO bldgarea |
| Building type | first letter of bldg_class | PLUTO bldgclass |
| ZIP code | zip | PLUTO zipcode |
| Building has: landmark, historic district, condominium | landmark_name, historic_district, condo_no present | PLUTO landmark, histdist, condono |
| Our buildings | tenant_buildings (active), the tenant's own list (map_tenant_overlay) | Complied |
Building has (building_flags):
| Fact | Record | Feed (dataset) |
|---|---|---|
| Elevator | asset elevator, active | DOB elevators (e5aq-a4j2) |
| Boiler | asset boiler | DOB boilers (52dp-yji6) |
| Cooling tower | asset cooling_tower, active | DOHMH cooling towers (y4fw-iqfr) |
| Water tank | asset water_tank | DOHMH water tanks (gjm4-k24g) |
| Facade unsafe / SWARMP | latest filing fisp | DOB facades (xubg-57si) |
| HPD registration expired | filing hpd_registration, expires_on before today | HPD registrations (tesw-yqqr) |
| Energy Star under 50 | latest filing ll84_benchmarking | LL84 benchmarking (5zyy-y8am) |
| Active DOB permit | filing work_permit, expires_on today or later | DOB NOW permits (rbx6-tga4), BIS permits (ipu4-2q9a) |
| Temporary CO only | newest filing certificate_of_occupancy | DOB certificates of occupancy (bs8b-p36w, pkdm-hqz6) |
| Tax abatement, Tax exemption | filing tax_abatement, tax_exemption | DOF abatements (rgyu-ii48), exemptions (muvi-b6kx) |
| On the CONH list | designation conh_pilot, not ended | HPD CONH (bzxi-2tsw) |
| Under HPD jurisdiction | designation hpd_jurisdiction, Active | HPD jurisdiction (kj4p-ruqc) |
Our work (Apartment needs, Apartment due, Apartment facts): not in the snapshot. The browser reads obligation_facts_for_portfolio (the tenant's apartments, notices, investigations, turnovers and LL31 tests) and runs needsForUnit() over it (useObligationFilter), then sends the matching buildings as buildingIds. These need view_obligations.
Changing common things
| To | Do this |
|---|---|
| Add a filter | Its fields on MapFacets and EMPTY_FACETS, and one MAP_FILTERS entry (quick: true puts it on the bar as its own dropdown; otherwise it lives under All filters) (mapFilterRegistry.ts); listFilter and dateRangeFilter build the common shapes. Then the map-service side: the MapQueryFilter key it sends (contract), its predicate in normalizeFilter / compilePredicates (sql.ts), and an exporter column if the snapshot lacks it, with a snapshot probe if old snapshots do (as hasIssuedColumn). The bar, chips, sentence, URL and saved views need nothing. The date filters are the worked example; the detail filters (HPD class, rent impairing, apartment or public area, building type, floors, ZIP) are the example of new snapshot columns, probed together by hasDetailColumns (DETAIL_COLUMNS in sql.ts). Smart search does not learn it until search.ts's schema and searchVocabulary.ts do. |
| Add a preset | Add an entry to MAP_PRESETS, and its id to searchVocabulary.ts's lenses (smartSearch.test.ts checks). No SQL change. |
| Add a "Building has" fact | A column in building_flags and refresh_building_flags() (migration); a BUILDING_FEATURES entry in buildingFeatures.ts; and the column and predicate in FEATURE_SQL (sql.ts), which fails to compile without it. The menu, the chips, BuildingFeature and smart search read the list, so they need nothing. The exporter carries every flag column and the snapshot probe reads FEATURE_COLUMNS, so neither needs an edit. |
| Add a status key | MAP_STATUS_OPTIONS, the predicates in sql.ts, and the taxonomy if needed. Keep searchVocabulary.ts aligned; smartSearch.test.ts fails on drift. |
| Add a map layer (an event feed's records) | One migration inserting its map_event_sources row: source-id prefix, department, fallback category, the order-code and agency-status columns, and category_by only if the category is chosen per row. A new department or category also needs map_departments / map_categories rows. The view needs no change; the next export carries the layer. If it is a new department with its own feed, add it to AGENCY_ON_MAP in mapAgencies.ts so "Everything open" and the violations and map links include it. |
| Add a department or agency mapping | Edit AGENCY_ON_MAP in mapAgencies.ts, the one table behind INGESTED_DEPARTMENTS, agenciesForDept and mapWhatForAgencies. OATH is the exception: it hears summonses whoever issued them, so a DOHMH summons is agency = 'dohmh' on the violations page but the oath.dohmh category on the map. List the OATH category (oath.<agency>) for the summonses, and a department only for records the agency's own feed holds. mapAgencies.test.ts checks both directions. |
| Remap an HPD order to a category | Edit the taxonomy tables through a migration, not TypeScript. |
| Change the pin threshold | MAX_POINTS and the pins CASE in sql.ts. DuckDB plans both branches of a CASE, subqueries included, so bound anything expensive inside a branch. |
| Change the ranked limit | MAP_RANKED_LIMIT in mapQuery.ts and the service's limit handling. |
| Change obligation logic | mapObligations.ts only, so /deadlines and the map share one ladder. |
Gotcha: getRowObjectsJson() returns BIGINT as a string; cast counts to INTEGER or build JSON in SQL with to_json, as sql.ts does.
Tests
| Suite | Covers |
|---|---|
map-service npm test | SQL compiler, a fixture world, the HTTP layer, smart search, the snapshot runtime, the Lambda adapter, the tenant line |
src/lib/mapFilter.test.ts, src/components/map/mapFacets.test.ts | What-tree collapse and summaries; URL codec, presets, the palette, the violations link |
src/components/map/mapFilterRegistry.test.ts | Every old link in mapUrlFixtures.json still sends the same filter (as is, rewritten, and as a saved view); the registry's shape; chips, the sentence, the scope lock; the date filters |
src/lib/mapAgencies.test.ts | The agency table in both directions: each department's agencies land back on that department, and each agency's records land on the map |
src/lib/mapObligations.test.ts, src/lib/mapStreetView.test.ts | Obligation facets; street-image lookup |
src/components/map/buildingDisplay.test.ts, popupSummary.test.ts, buildingMatch.test.ts, mapStyle.test.ts | Building formatting and links; the popup under each kind of filter; pin-to-footprint matching; layer styles |
src/components/map/smartSearch.test.ts | Frontend and service vocabularies stay in sync |
src/components/map/mapFrame.test.ts | The side-pane frame and the phone sheet, the view switch's plan, the crew layers, the hover card, the Google camera hand-over and script URL |
db-tests/tests/leakage.test.ts | map_tenant_overlay cross-tenant leakage |