Inspiration

NYC's collision data is public, but it isn't accessible. Answering an ordinary local question — "what has actually been reported at my corner?" — currently takes GIS knowledge, dataset research, and careful interpretation. The data sits in a Socrata portal behind a query language, spread across three datasets that don't obviously join.

That gap matters because a printed, sourced, one-corner safety report is exactly the artifact New Yorkers are already expected to produce and can't easily get: for an Open Streets or traffic-calming application, for community board testimony, for a school-crossing question, or for a reporter who needs a defensible figure under deadline.

So we picked the challenge of access to city resources — specifically, access to the city's own street-safety record.

What it does

You select one official NYC street intersection on a map. EZStreet locks the city's own name for that intersection, draws a fixed 50-meter analysis boundary, and generates a deterministic street-safety report for calendar year 2025:

  • Headline facts — crashes, people injured, people killed
  • Road-user breakdown — pedestrians, cyclists, motorists, injured and killed
  • Contributing factors, ranked, with unspecified counted separately
  • Vision Zero Priority Zone overlap, tested geometrically
  • Every dataset it used, with Socrata ID, role, availability status, and retrieval timestamp
  • Data limitations, stated on the report itself

Then you print it, or save it as a PDF — as a real print document, not a screenshot of the screen.

Three things it deliberately refuses to do:

  1. It never invents a number. If a source degrades, the report is labelled Partial, valid results are kept, the missing piece is named, and a retry is offered. A null metric renders as Unavailable; a 0 means a real query genuinely matched nothing. The two never collapse into each other.
  2. It never grades your street. A zero result is reported as a zero result — never as evidence that a corner is safe.
  3. It never lets a language model touch a fact.

How we built it

Next.js 16 (App Router) on Vercel, TypeScript, MapLibre GL JS with OpenFreeMap tiles, and @turf/boolean-intersects for the geometry. Three NYC Open Data datasets, each with exactly one deterministic role, all queried live at request time:

Dataset Socrata ID Role How we query it
NYC Street Centerline inkn-q76z Selection geometry within_box for the viewport in the browser; a WKT intersects(POLYGON…) window server-side, filtered to eligible surface streets
Motor Vehicle Collisions – Crashes h9gi-nx95 Collision metrics within_circle(location, lat, lon, 50) bounded to calendar year 2025
VZV Priority Zones or Areas qzji-nvbd Priority-zone context All five polygons fetched, then tested against the 50 m circle with a real geometric intersection

The novel part is the join, because NYC does not publish an intersection dataset. Every intersection in the app is derived: we group street-centerline endpoints on an exact longitude|latitude|level key with no snapping tolerance, name each candidate from the city's own stname_label, and require at least two distinct eligible street names before offering it as selectable. So the thing you click and the name printed on the report are the city's record, not our guess.

Inputs are server-authoritative. The browser cannot supply a query, a radius, a period, or a replacement official name — the server scans every string in the request for query markers, checks the coordinate against an NYC bounding box, rebuilds the request from its own constants, and re-resolves the submitted intersection against the centerline data to within roughly a centimetre.

The app runs with no API key and no signup.

Challenges we ran into

NYC has no intersection dataset. This was the real surprise, and it reshaped the project. Getting the derivation right — grouping, naming, the two-distinct-streets rule, and what to do about multi-roadbed nodes like Grand Central — took more work than the entire collision query.

Modelling missing data honestly. Deciding that null and 0 must never collapse into each other sounds academic until it propagates through the API contract, the type system, and eight distinct UI states. It's the decision we'd defend hardest.

Failure signalling needed to be a two-way contract. A source of truth throws: with no resolved intersection there is no truthful report, so the centerline adapter raises and the route returns 503. A degradable source resolves to a status instead, so a missing Priority Zone check still yields a truthful 200 partial report with the gap named. Picking "throw" for a degradable source would turn a legitimate partial report into a 500 for the whole request.

Keeping a viewport-driven fetch cancellable without leaking stale responses into the map.

Accomplishments that we're proud of

There is no LLM anywhere in the data path, and none at runtime in the live demo. We used AI tools heavily to build this — Claude Code and Codex CLI, all weekend — and then drew a hard line at the product. We had an "Explain this report" feature scoped and bounded by ADR-0005, and we deferred it rather than ship it unbounded against a deadline. For a tool that reports fatalities at a specific corner, a plausible-sounding wrong number is worse than no feature.

Human in the loop, with receipts:

  • main is branch-protected and requires a human approving review. No agent merged its own work.
  • Decisions expensive to reverse were made by humans first, in ADRs, before code existed.
  • A commit hook rejects AI Co-Authored-By trailers. Authorship stays with the people accountable for the code.
  • CI is the authority, not the model: lint, typecheck, ≥90% coverage, an accessibility scan, and a production build all gate merge.

Design and accessibility were deliverables, not byproducts. A map is a mouse-only control by default, so we built the other path too: a keyboard-navigable list of exactly the same intersection candidates in the viewport, with coordinates in each accessible name. The report drawer has eight explicit states, each with its own copy and live-region announcement — "no crashes matched" and "we couldn't reach the data" look and read completely differently, because they mean completely different things. And @axe-core/playwright asserts zero accessibility violations in CI across the map view, the report panel, and the print document.

Quality gates that actually hold: 275 tests, 99% coverage against a hard 90% floor, end-to-end Playwright flows against live NYC Open Data, and a clean production build — all enforced on every pull request.

What we learned

None of us had built this before.

  • Socrata and SoQL geospatial queryingwithin_circle, intersects with WKT polygon windows, and building a metre-accurate bounding box at NYC's latitude.
  • MapLibre GL's layer and interaction model — sources, layers, hit targets, hover state, and cancellable viewport-driven fetches.
  • That NYC has no intersection dataset, and what it takes to derive one defensibly.
  • Modelling missing data honestly, and how far that decision reaches once you commit to it.
  • Running a multi-agent AI workflow across two different CLIs — Claude Code and Codex CLI against one checked-in agent roster — without losing traceability of who, human or agent, decided what.

What's next for EZStreet

  • "Explain this report" — already scoped and bounded by ADR-0005: the model may only receive a finished report and restate it in plainer language, never compute, alter, hide, or gate a fact.
  • Resolving multi-roadbed nodes such as Grand Central into a single named intersection rather than separate candidates.
  • Street-segment buffers in addition to single-corner circles.
  • Petition and permit support for Open Streets applications, and editable exports.

Built With

Share this project:

Updates