seriffic commited on
Commit
304d3b7
·
1 Parent(s): c7a018d

Bulk register + compare modes

Browse files

Two more user-facing modes on top of the address endpoint:

/compare — side-by-side briefings for two queries, useful
when picking between candidate parcels or
comparing two neighborhoods.

/register/{class} — pre-computed exposure for an entire asset
register: NYC DOE schools, NYCHA developments,
MTA subway entrances. The JSON ships baked
(built offline by register_builder), so the
UI is just paginated rendering — every row
shows the same paragraph + tier the address
mode would produce.

Asset loaders normalise each register's source GeoJSON into the
common (name, lat, lon, props) shape register_builder expects.

.gitattributes CHANGED
@@ -3,6 +3,9 @@
3
  *.tif filter=lfs diff=lfs merge=lfs -text
4
  *.pdf filter=lfs diff=lfs merge=lfs -text
5
 
 
 
 
6
  # Esri FileGDB internal binary files (DEP Stormwater scenario data)
7
  *.gdbtable filter=lfs diff=lfs merge=lfs -text
8
  *.gdbtablx filter=lfs diff=lfs merge=lfs -text
 
3
  *.tif filter=lfs diff=lfs merge=lfs -text
4
  *.pdf filter=lfs diff=lfs merge=lfs -text
5
 
6
+ # Pre-computed register paragraphs
7
+ data/registers/*.json filter=lfs diff=lfs merge=lfs -text
8
+
9
  # Esri FileGDB internal binary files (DEP Stormwater scenario data)
10
  *.gdbtable filter=lfs diff=lfs merge=lfs -text
11
  *.gdbtablx filter=lfs diff=lfs merge=lfs -text
app/assets/mta_entrances.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MTA Subway Entrances and Exits (NY OpenData i9wp-a4ja).
2
+
3
+ ~1,900 subway entrances city-wide. The MTA Climate Resilience Roadmap
4
+ (Oct 2025) names ~1,500 of these as priorities for sealing — this is
5
+ exactly the asset class our RAG corpus has the most to say about, and
6
+ exactly the audience (MTA capital planners, transit advocacy) the
7
+ register is built for.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import geopandas as gpd
15
+ import httpx
16
+
17
+ from app.spatial import DATA, NYC_CRS
18
+
19
+ URL = "https://data.ny.gov/api/geospatial/i9wp-a4ja?method=export&format=GeoJSON"
20
+ LOCAL = DATA / "mta_entrances.geojson"
21
+
22
+
23
+ def _ensure_fixture() -> Path:
24
+ if LOCAL.exists():
25
+ return LOCAL
26
+ print("downloading MTA Subway Entrances (one-time)...", flush=True)
27
+ r = httpx.get(URL, timeout=60)
28
+ r.raise_for_status()
29
+ LOCAL.write_text(r.text)
30
+ return LOCAL
31
+
32
+
33
+ def load() -> gpd.GeoDataFrame:
34
+ _ensure_fixture()
35
+ g = gpd.read_file(LOCAL)
36
+ if g.crs is None:
37
+ g.set_crs("EPSG:4326", inplace=True)
38
+ g = g.to_crs(NYC_CRS)
39
+ rename_map = {
40
+ "stop_name": "name",
41
+ "constrained_floor_to_floor_height": None,
42
+ "borough": "borough",
43
+ "entrance_type": "entrance_type",
44
+ "ada": "ada",
45
+ "north_south_street": "ns_street",
46
+ "east_west_street": "ew_street",
47
+ "corner": "corner",
48
+ }
49
+ for k, v in rename_map.items():
50
+ if v and k in g.columns and k != v:
51
+ g = g.rename(columns={k: v})
52
+
53
+ # build a usable address-style label
54
+ def label(row):
55
+ nm = (row.get("name") or "").strip()
56
+ ns = (row.get("ns_street") or "").strip()
57
+ ew = (row.get("ew_street") or "").strip()
58
+ cn = (row.get("corner") or "").strip()
59
+ bits = [nm]
60
+ cross = " & ".join(b for b in [ns, ew] if b)
61
+ if cross: bits.append(cross)
62
+ if cn: bits.append(f"({cn})")
63
+ return ", ".join([b for b in bits if b])
64
+
65
+ g["address"] = g.apply(label, axis=1)
66
+ if "borough" in g.columns:
67
+ boro_map = {"M": "Manhattan", "Bk": "Brooklyn", "B": "Brooklyn",
68
+ "Q": "Queens", "Bx": "Bronx", "SI": "Staten Island"}
69
+ g["borough"] = g["borough"].astype(str).map(lambda v: boro_map.get(v, v.title()))
70
+
71
+ keep = [c for c in ["name", "address", "borough", "entrance_type",
72
+ "ada", "ns_street", "ew_street", "corner", "geometry"]
73
+ if c in g.columns]
74
+ return g[keep].copy()
app/assets/nycha.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NYCHA Developments (NYC OpenData phvi-damg).
2
+
3
+ 326 public-housing developments across NYC. Used as an asset class for
4
+ the bulk-mode register; the parent rationale for surfacing this layer
5
+ is that NYCHA was hit hard by Sandy and remains a published Tier-1
6
+ flood-resilience priority in the city's Hazard Mitigation Plan.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import geopandas as gpd
11
+
12
+ from app.spatial import DATA, load_layer
13
+
14
+
15
+ def load() -> gpd.GeoDataFrame:
16
+ g = load_layer(DATA / "nycha.geojson")
17
+ # NYCHA developments come back as polygons; the FSM expects point
18
+ # geometry for spatial joins. Use centroid.
19
+ g = g.copy()
20
+ g["geometry"] = g.geometry.centroid
21
+
22
+ # NYCHA Developments has only `developmen` (truncated label), tds_num, borough.
23
+ g = g.rename(columns={"developmen": "name"})
24
+ g["address"] = g["name"] # the field doubles as both
25
+ g["borough"] = g["borough"].str.title() # "BRONX" -> "Bronx" to match Riprap convention
26
+
27
+ keep = [c for c in ["name", "address", "borough", "tds_num", "geometry"] if c in g.columns]
28
+ return g[keep].copy()
app/assets/schools.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NYC DOE School Point Locations (Socrata a3nt-yts4)."""
2
+ from __future__ import annotations
3
+
4
+ import geopandas as gpd
5
+
6
+ from app.spatial import DATA, load_layer
7
+
8
+ BORO = {"1": "Manhattan", "2": "Bronx", "3": "Brooklyn", "4": "Queens", "5": "Staten Island"}
9
+
10
+
11
+ def load() -> gpd.GeoDataFrame:
12
+ g = load_layer(DATA / "schools.geojson")
13
+ g = g.rename(columns={
14
+ "loc_code": "loc_code",
15
+ "loc_name": "name",
16
+ "address": "address",
17
+ "bbl": "bbl",
18
+ "bin": "bin",
19
+ "boronum": "boro_num",
20
+ "geodistric": "geo_district",
21
+ "adimindist": "admin_district",
22
+ })
23
+ g["borough"] = g["boro_num"].astype(str).map(BORO)
24
+ g["bbl"] = g["bbl"].astype(str).str.replace(r"\.0$", "", regex=True)
25
+ keep = ["loc_code", "name", "address", "borough", "bbl", "bin",
26
+ "geo_district", "admin_district", "geometry"]
27
+ return g[keep].copy()
app/register_builder.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generic per-asset register builder.
2
+
3
+ Runs the same FSM specialists over every asset in a class. Tier 1+2
4
+ get full Granite paragraphs; Tier 3 gets signals only (paragraph
5
+ generated on click in the UI).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ import geopandas as gpd
16
+ from shapely.geometry import Point
17
+
18
+ from app.context import floodnet, microtopo, nyc311
19
+ from app.flood_layers import dep_stormwater, ida_hwm, sandy_inundation
20
+ from app.rag import retrieve as rag_retrieve, warm as rag_warm
21
+ from app.reconcile import reconcile as run_reconcile
22
+ from app.score import score_frame
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ REGISTERS_DIR = ROOT / "data" / "registers"
26
+
27
+
28
+ def _build_one(row_meta: dict, geom_2263, lat: float, lon: float,
29
+ with_paragraph: bool) -> dict:
30
+ pt = gpd.GeoDataFrame(geometry=[geom_2263], crs="EPSG:2263")
31
+ sandy = bool(sandy_inundation.join(pt).iloc[0])
32
+ dep = {}
33
+ for scen in ["dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current"]:
34
+ j = dep_stormwater.join(pt, scen).iloc[0]
35
+ dep[scen] = {
36
+ "depth_class": int(j["depth_class"]),
37
+ "depth_label": j["depth_label"],
38
+ "citation": f"NYC DEP Stormwater Flood Map — {dep_stormwater.label(scen)}",
39
+ }
40
+ fn = floodnet.summary_for_point(lat, lon, 600); fn["radius_m"] = 600
41
+ n311 = nyc311.summary_for_point(lat, lon, 200, 5)
42
+ mt_obj = microtopo.microtopo_at(lat, lon)
43
+ mt = vars(mt_obj) if mt_obj else None
44
+ ida_obj = ida_hwm.summary_for_point(lat, lon, 800)
45
+ ida = vars(ida_obj) if ida_obj else None
46
+
47
+ snap = {
48
+ "geocode": {**row_meta, "lat": lat, "lon": lon},
49
+ "sandy": sandy, "dep": dep, "floodnet": fn, "nyc311": n311,
50
+ "microtopo": mt, "ida_hwm": ida,
51
+ }
52
+ if with_paragraph:
53
+ rag_query = (f"flood risk for {row_meta.get('name','')} in "
54
+ f"{row_meta.get('borough','')}, NYC; resilience plan, "
55
+ f"vulnerability, mitigation")
56
+ snap["rag"] = rag_retrieve(rag_query, k=2, min_score=0.55)
57
+ para, audit = run_reconcile(snap, return_audit=True)
58
+ snap["paragraph"] = para
59
+ snap["audit"] = audit
60
+ return snap
61
+
62
+
63
+ def build_register(asset_class: str, loader: Callable, *,
64
+ tier_with_paragraph: tuple[int, ...] = (1, 2),
65
+ meta_keys: tuple[str, ...] = ("name", "address", "borough"),
66
+ regenerate: bool = False) -> Path:
67
+ """Build a register JSON for an asset class.
68
+
69
+ Args:
70
+ asset_class: short id (also the output filename)
71
+ loader: zero-arg callable returning a GeoDataFrame in EPSG:2263 with
72
+ point geometry and at least the columns in meta_keys
73
+ tier_with_paragraph: which tiers get full Granite reconciliation
74
+ meta_keys: which row columns to surface as the geocode-style metadata
75
+ """
76
+ out = REGISTERS_DIR / f"{asset_class}.json"
77
+ if out.exists() and not regenerate:
78
+ print(f"already exists: {out}; pass regenerate=True to rebuild",
79
+ file=sys.stderr)
80
+ return out
81
+ REGISTERS_DIR.mkdir(exist_ok=True, parents=True)
82
+
83
+ print(f"loading asset class {asset_class!r}...", file=sys.stderr)
84
+ g = loader()
85
+ if g.crs is None or g.crs.to_string() != "EPSG:2263":
86
+ g = g.to_crs("EPSG:2263")
87
+
88
+ # tier each asset off the same rubric (sandy + 3 DEP scenarios)
89
+ g["sandy"] = sandy_inundation.join(g).astype(int)
90
+ for scen in ["dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current"]:
91
+ j = dep_stormwater.join(g, scen)
92
+ g[scen] = (j["depth_class"] > 0).astype(int)
93
+ g = score_frame(g)
94
+ g["lat"] = g.geometry.to_crs("EPSG:4326").y
95
+ g["lon"] = g.geometry.to_crs("EPSG:4326").x
96
+
97
+ targets = g[g["tier"].isin([1, 2, 3])].copy()
98
+ print(f" {len(targets)} of {len(g)} assets at Tier 1-3", file=sys.stderr)
99
+ print("warming RAG index...", file=sys.stderr)
100
+ rag_warm()
101
+
102
+ # Resume support: a partial JSON sits next to the final output. We
103
+ # write it after every row, so any blip can be retried without losing
104
+ # work.
105
+ partial = REGISTERS_DIR / f"{asset_class}.partial.json"
106
+ rows: list[dict] = []
107
+ done_keys: set = set()
108
+ if partial.exists():
109
+ try:
110
+ data = json.loads(partial.read_text())
111
+ rows = data.get("rows", [])
112
+ # use lat/lon as the unique key (works for any asset class)
113
+ done_keys = {(round(r["lat"], 5), round(r["lon"], 5)) for r in rows}
114
+ print(f" resuming with {len(rows)} rows already processed",
115
+ file=sys.stderr)
116
+ except Exception as e:
117
+ print(f" failed to read partial, starting fresh: {e}",
118
+ file=sys.stderr)
119
+
120
+ t0 = time.time()
121
+ for i, (_, row) in enumerate(
122
+ targets.sort_values(["score", "name"], ascending=[False, True]).iterrows()):
123
+ key = (round(float(row["lat"]), 5), round(float(row["lon"]), 5))
124
+ if key in done_keys:
125
+ continue
126
+ tier = int(row["tier"])
127
+ with_paragraph = tier in tier_with_paragraph
128
+ meta = {k: row.get(k) for k in meta_keys}
129
+ try:
130
+ snap = _build_one(meta, row["geometry"],
131
+ float(row["lat"]), float(row["lon"]),
132
+ with_paragraph=with_paragraph)
133
+ except Exception as e:
134
+ print(f" [{i+1}/{len(targets)}] FAILED tier-{tier} "
135
+ f"{str(meta.get('name',''))[:50]} -- {type(e).__name__}: {e}",
136
+ file=sys.stderr)
137
+ time.sleep(2) # back off on transient errors
138
+ continue
139
+
140
+ rec: dict[str, Any] = {
141
+ **{k: row.get(k) for k in g.columns if k != "geometry"},
142
+ "lat": float(row["lat"]),
143
+ "lon": float(row["lon"]),
144
+ "score": int(row["score"]),
145
+ "tier": tier,
146
+ "snap": snap,
147
+ }
148
+ rows.append(rec)
149
+ done_keys.add(key)
150
+ # incremental persist
151
+ partial.write_text(json.dumps({
152
+ "asset_class": asset_class,
153
+ "rows": rows,
154
+ }, default=str))
155
+ elapsed = time.time() - t0
156
+ print(f" [{i+1}/{len(targets)}] tier-{tier} "
157
+ f"{str(meta.get('name',''))[:50]:<50} "
158
+ f"({elapsed:.0f}s elapsed)", file=sys.stderr)
159
+
160
+ out.write_text(json.dumps({
161
+ "asset_class": asset_class,
162
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
163
+ "rows": rows,
164
+ }, default=str))
165
+ if partial.exists():
166
+ partial.unlink()
167
+ print(f"\nwrote {len(rows)} rows -> {out} ({out.stat().st_size // 1024} KB)",
168
+ file=sys.stderr)
169
+ return out
data/mta_entrances.geojson ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8f5cc2ac27763a44bc7c9166e229f221dd5ffb285bf148bc4c12a326b23b8072
3
+ size 939323
data/nycha.geojson ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6f3a4da7938f0e70c00b8497aabdc0e5dede8e9bc4940abbf0c35171ba831fa4
3
+ size 308085
data/registers/nycha.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:429ee990e27b90cb824678eaf7679cd6efc385103c564d1acc97b39766183a0e
3
+ size 160508
data/registers/schools.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5fb2aa47325d8214453016e0f7813650e4b347b2976edef0590ed9a5af0d6058
3
+ size 448698
data/schools.geojson ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d67aa09b8385d984ed41caf042431c5e86406143e0cc14b52a2fc5d3d308f888
3
+ size 897297
web/static/compare.html ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Riprap — compare two NYC addresses</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
9
+ <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
10
+ </head>
11
+ <body class="compare-mode">
12
+
13
+ <header class="topbar">
14
+ <div class="topbar-inner">
15
+ <div class="brand">
16
+ <span class="brand-name">Riprap</span>
17
+ <span class="brand-sep">·</span>
18
+ <span class="brand-tag">compare two NYC addresses, side by side</span>
19
+ </div>
20
+ <div class="topbar-right">
21
+ <a href="/" class="modelink">address</a>
22
+ <a href="/register/schools" class="modelink">register</a>
23
+ <span class="local-pill" title="Granite 4.1 inference runs on this machine. Two FSMs run in parallel.">
24
+ <span class="dot"></span>local · 2× Granite 4.1
25
+ </span>
26
+ </div>
27
+ </div>
28
+ </header>
29
+
30
+ <div class="form-bar form-bar-compare">
31
+ <form id="cform">
32
+ <div class="cform-row">
33
+ <label>A</label>
34
+ <input type="text" id="qa" name="a" autocomplete="off"
35
+ placeholder='Address A — e.g. "153-09 90 Avenue, Jamaica, Queens"'
36
+ value="153-09 90 Avenue, Jamaica, Queens" required />
37
+ </div>
38
+ <div class="cform-row">
39
+ <label>B</label>
40
+ <input type="text" id="qb" name="b" autocomplete="off"
41
+ placeholder='Address B — e.g. "350 5 Avenue, Manhattan" (Empire State)'
42
+ value="350 5 Avenue, Manhattan" required />
43
+ </div>
44
+ <button type="submit" id="cgo">Compare</button>
45
+ </form>
46
+ <div class="suggest">
47
+ <span class="suggest-label">try:</span>
48
+ <button class="chip" data-a="153-09 90 Avenue, Jamaica, Queens" data-b="350 5 Avenue, Manhattan">Ida basements vs. Empire State</button>
49
+ <button class="chip" data-a="180 Beach 35 St, Queens" data-b="2950 W 25 St, Brooklyn">Far Rockaway vs. Coney Island NYCHA</button>
50
+ <button class="chip" data-a="Smith and 9 Street, Brooklyn" data-b="220 W 109 St, Manhattan">Gowanus vs. Upper West Side</button>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="workbench compare-workbench">
55
+
56
+ <!-- LEFT pane: address A -->
57
+ <aside class="cpane" id="paneA">
58
+ <div class="cpane-head"><span class="ctag a">A</span><span id="aTitle">Address A</span></div>
59
+ <section class="panel">
60
+ <h2>Trace<span class="hint">Burr FSM · A</span></h2>
61
+ <ul id="stepsA"></ul>
62
+ </section>
63
+ <section class="panel hidden" id="reportA">
64
+ <h2>Cited report<span class="hint">Granite 4.1 · A</span></h2>
65
+ <div class="report-section"><h3>At a glance</h3><ul id="glanceA" class="glance-list"></ul></div>
66
+ <div class="report-section"><h3>Summary</h3><p id="paragraphA"></p></div>
67
+ <div class="report-section"><h3>Sources fired</h3><ol id="sourcesA" class="sources-list"></ol></div>
68
+ </section>
69
+ </aside>
70
+
71
+ <!-- MIDDLE pane: shared map -->
72
+ <section class="col-mid compare-mid">
73
+ <div class="panel panel-map" id="map-card">
74
+ <h2>Map<span class="hint">Both addresses · shared layers</span></h2>
75
+ <div id="map"></div>
76
+ <div class="legend">
77
+ <span><i class="sw sandy"></i>Sandy 2012</span>
78
+ <span><i class="sw dep"></i>DEP Extreme 2080</span>
79
+ <span><i class="sw fnDot"></i>FloodNet (no events)</span>
80
+ <span><i class="sw fnDotHot"></i>FloodNet w/ events</span>
81
+ <span><i class="sw addr"></i>A &amp; B markers</span>
82
+ </div>
83
+ </div>
84
+ </section>
85
+
86
+ <!-- RIGHT pane: address B -->
87
+ <aside class="cpane" id="paneB">
88
+ <div class="cpane-head"><span class="ctag b">B</span><span id="bTitle">Address B</span></div>
89
+ <section class="panel">
90
+ <h2>Trace<span class="hint">Burr FSM · B</span></h2>
91
+ <ul id="stepsB"></ul>
92
+ </section>
93
+ <section class="panel hidden" id="reportB">
94
+ <h2>Cited report<span class="hint">Granite 4.1 · B</span></h2>
95
+ <div class="report-section"><h3>At a glance</h3><ul id="glanceB" class="glance-list"></ul></div>
96
+ <div class="report-section"><h3>Summary</h3><p id="paragraphB"></p></div>
97
+ <div class="report-section"><h3>Sources fired</h3><ol id="sourcesB" class="sources-list"></ol></div>
98
+ </section>
99
+ </aside>
100
+
101
+ </div>
102
+
103
+ <footer>
104
+ <div class="foot-inner">
105
+ <div class="foot-col">
106
+ <h3>Compare mode</h3>
107
+ <p>
108
+ Two addresses, two parallel runs of the same 8-specialist FSM.
109
+ Each side renders its own trace, at-a-glance signals, cited
110
+ summary, and source list. The map shows both markers and shared
111
+ layer overlays. Both Granite 4.1 reconciliations run concurrently
112
+ via Ollama (<code>OLLAMA_NUM_PARALLEL=2</code>); wallclock is
113
+ comparable to a single query.
114
+ </p>
115
+ </div>
116
+ <div class="foot-col">
117
+ <h3>Sources</h3>
118
+ <p>
119
+ <a href="https://data.cityofnewyork.us/" target="_blank">NYC Open Data</a> ·
120
+ <a href="https://api.floodnet.nyc/" target="_blank">FloodNet NYC</a> ·
121
+ <a href="https://geosearch.planninglabs.nyc/" target="_blank">NYC DCP Geosearch</a> ·
122
+ <a href="https://stn.wim.usgs.gov/" target="_blank">USGS STN</a> ·
123
+ <a href="https://www.usgs.gov/3d-elevation-program" target="_blank">USGS 3DEP</a>
124
+ </p>
125
+ </div>
126
+ </div>
127
+ </footer>
128
+
129
+ <script src="/static/compare.js"></script>
130
+ </body>
131
+ </html>
web/static/compare.js ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Riprap — Compare mode. Two addresses, parallel FSM runs, shared map.
2
+
3
+ const STEP_LABELS = {
4
+ geocode: ["Geocode (DCP Geosearch)", "address → lat/lon, BBL"],
5
+ sandy_inundation: ["Sandy Inundation (NYC OD)", "empirical 2012 extent"],
6
+ dep_stormwater: ["DEP Stormwater Maps", "pluvial scenarios + 2080 SLR"],
7
+ floodnet: ["FloodNet sensor network", "live ultrasonic depth sensors"],
8
+ nyc311: ["NYC 311 archive", "flood complaints in buffer"],
9
+ microtopo_lidar: ["LiDAR terrain (DEM + TWI + HAND)", "USGS 3DEP DEM + whitebox hydrology"],
10
+ ida_hwm_2021: ["Ida 2021 high-water marks", "USGS empirical post-event extent"],
11
+ prithvi_eo_v2: ["Prithvi-EO 2.0 (300M, NASA/IBM)", "Sen1Floods11 satellite water segmentation"],
12
+ rag_granite_embedding: ["Granite Embedding 278M (RAG)", "policy corpus retrieval"],
13
+ reconcile_granite41: ["Granite 4.1 reconcile (local)", "document-grounded synthesis"],
14
+ };
15
+
16
+ const STEPS_ORDER = [
17
+ "geocode", "sandy_inundation", "dep_stormwater", "floodnet", "nyc311",
18
+ "microtopo_lidar", "ida_hwm_2021", "prithvi_eo_v2",
19
+ "rag_granite_embedding", "reconcile_granite41",
20
+ ];
21
+
22
+ const SOURCE_LABELS = {
23
+ geocode: "NYC DCP Geosearch",
24
+ sandy: "NYC OpenData 5xsi-dfpx — Sandy 2012 inundation",
25
+ dep_extreme_2080: "NYC DEP Stormwater — Extreme 3.66 in/hr + 2080 SLR",
26
+ dep_moderate_2050: "NYC DEP Stormwater — Moderate 2.13 in/hr + 2050 SLR",
27
+ dep_moderate_current: "NYC DEP Stormwater — Moderate 2.13 in/hr current",
28
+ floodnet: "FloodNet NYC — live ultrasonic sensor network",
29
+ nyc311: "NYC 311 (Socrata erm2-nwe9) — flood descriptors",
30
+ microtopo: "USGS 3DEP 30 m DEM via py3dep",
31
+ ida_hwm: "USGS STN — Hurricane Ida 2021 HWMs (Event 312, NY)",
32
+ prithvi_water: "Prithvi-EO 2.0 (300M, NASA/IBM) Sen1Floods11 — satellite water segmentation",
33
+ rag_dep_2013: "NYC DEP Wastewater Resiliency Plan (2013)",
34
+ rag_nycha: "NYCHA — Flood Resilience: Lessons Learned",
35
+ rag_coned: "Con Edison Climate Change Resilience Plan (Case 22-E-0222)",
36
+ rag_mta: "MTA Climate Resilience Roadmap (Oct 2025)",
37
+ rag_comptroller: "NYC Comptroller — \"Is NYC Ready for Rain?\" (2024)",
38
+ };
39
+
40
+ const $ = (s) => document.querySelector(s);
41
+
42
+ let evtSrc = null;
43
+ let map = null;
44
+ let mapInit = false;
45
+
46
+ const MAP_STYLE = {
47
+ version: 8,
48
+ sources: {
49
+ carto: {
50
+ type: "raster",
51
+ tiles: ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"],
52
+ tileSize: 256,
53
+ attribution: "© OpenStreetMap contributors © CARTO",
54
+ },
55
+ },
56
+ layers: [
57
+ { id: "bg", type: "background", paint: { "background-color": "#fafbfd" } },
58
+ { id: "carto", type: "raster", source: "carto" },
59
+ ],
60
+ };
61
+
62
+ function ensureMap() {
63
+ if (mapInit) return;
64
+ mapInit = true;
65
+ map = new maplibregl.Map({
66
+ container: "map",
67
+ style: MAP_STYLE,
68
+ center: [-74.0, 40.72],
69
+ zoom: 10,
70
+ attributionControl: { compact: true },
71
+ });
72
+ map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "top-right");
73
+ map.on("load", () => {
74
+ for (const sideKey of ["a", "b"]) {
75
+ map.addSource("sandy_" + sideKey, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
76
+ map.addLayer({ id: "sandy_" + sideKey + "-fill", type: "fill", source: "sandy_" + sideKey,
77
+ paint: { "fill-color": "#fc5d52", "fill-opacity": 0.22 } });
78
+ map.addSource("dep_" + sideKey, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
79
+ map.addLayer({ id: "dep_" + sideKey + "-fill", type: "fill", source: "dep_" + sideKey,
80
+ paint: {
81
+ "fill-color": ["match", ["get", "Flooding_Category"],
82
+ 1, "#568adf", 2, "#1642DF", 3, "#031553", "#568adf"],
83
+ "fill-opacity": 0.28 } });
84
+ map.addSource("fn_" + sideKey, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
85
+ map.addLayer({ id: "fn_" + sideKey + "-circles", type: "circle", source: "fn_" + sideKey,
86
+ paint: {
87
+ "circle-radius": 5,
88
+ "circle-color": ["case", [">", ["get", "n_events_3y"], 0], "#fc5d52", "#1a8754"],
89
+ "circle-stroke-color": "#ffffff",
90
+ "circle-stroke-width": 1.5,
91
+ } });
92
+ }
93
+ map.addSource("addr_a", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
94
+ map.addLayer({ id: "addr_a-marker", type: "circle", source: "addr_a",
95
+ paint: { "circle-radius": 9, "circle-color": "#1642DF", "circle-stroke-color": "#fff", "circle-stroke-width": 2.5 } });
96
+ map.addSource("addr_b", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
97
+ map.addLayer({ id: "addr_b-marker", type: "circle", source: "addr_b",
98
+ paint: { "circle-radius": 9, "circle-color": "#9333ea", "circle-stroke-color": "#fff", "circle-stroke-width": 2.5 } });
99
+ });
100
+ }
101
+
102
+ function resetSide(side) {
103
+ const ul = document.getElementById("steps" + side.toUpperCase());
104
+ ul.innerHTML = "";
105
+ for (const sid of STEPS_ORDER) {
106
+ const [lbl, hint] = STEP_LABELS[sid] || [sid, ""];
107
+ const li = document.createElement("li");
108
+ li.id = `step-${side}-${sid}`;
109
+ li.className = "pending";
110
+ li.innerHTML = `
111
+ <span class="icon">○</span>
112
+ <div>
113
+ <div class="label">${lbl}</div>
114
+ <div class="meta">${hint}</div>
115
+ </div>
116
+ <span class="meta time"></span>`;
117
+ ul.appendChild(li);
118
+ }
119
+ document.getElementById("step-" + side + "-" + STEPS_ORDER[0]).classList.replace("pending", "running");
120
+
121
+ document.getElementById("report" + side.toUpperCase()).classList.add("hidden");
122
+ document.getElementById("paragraph" + side.toUpperCase()).innerHTML = "";
123
+ document.getElementById("glance" + side.toUpperCase()).innerHTML = "";
124
+ document.getElementById("sources" + side.toUpperCase()).innerHTML = "";
125
+ }
126
+
127
+ function markStep(side, stepId, ev) {
128
+ const li = document.getElementById(`step-${side}-${stepId}`);
129
+ if (!li) return;
130
+ li.className = ev.ok ? "ok" : "err";
131
+ li.querySelector(".icon").textContent = ev.ok ? "✓" : "✗";
132
+ if (ev.elapsed_s != null) {
133
+ li.querySelector(".time").textContent = ev.elapsed_s.toFixed(2) + "s";
134
+ }
135
+ if (ev.result) {
136
+ let div = li.querySelector(".result");
137
+ if (!div) {
138
+ div = document.createElement("div"); div.className = "result";
139
+ li.appendChild(div);
140
+ }
141
+ div.textContent = formatResult(ev.result);
142
+ } else if (ev.err) {
143
+ let div = li.querySelector(".result");
144
+ if (!div) {
145
+ div = document.createElement("div"); div.className = "result";
146
+ li.appendChild(div);
147
+ }
148
+ div.textContent = "error: " + ev.err;
149
+ }
150
+ const idx = STEPS_ORDER.indexOf(stepId);
151
+ if (idx >= 0 && idx + 1 < STEPS_ORDER.length) {
152
+ const next = document.getElementById(`step-${side}-${STEPS_ORDER[idx + 1]}`);
153
+ if (next && next.classList.contains("pending")) next.classList.replace("pending", "running");
154
+ }
155
+ }
156
+
157
+ function formatResult(r) {
158
+ if (typeof r !== "object") return String(r);
159
+ return Object.entries(r)
160
+ .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`)
161
+ .join(" · ");
162
+ }
163
+
164
+ function renderParagraph(side, text) {
165
+ const html = (text || "").replace(/\[([a-z0-9_]+)\]/gi, (_, d) =>
166
+ `<span class="cite" title="document id: ${d}">[${d}]</span>`);
167
+ document.getElementById("paragraph" + side.toUpperCase()).innerHTML = html;
168
+ }
169
+
170
+ function renderGlance(side, ev) {
171
+ const ul = document.getElementById("glance" + side.toUpperCase());
172
+ if (!ul) return;
173
+ const rows = [];
174
+ if (ev.sandy) {
175
+ rows.push({c: "hit", mark: "■", html: "Inside <strong>Sandy 2012</strong> inundation extent"});
176
+ } else {
177
+ rows.push({c: "miss", mark: "□", html: "Outside Sandy 2012 inundation extent"});
178
+ }
179
+ const dep = ev.dep || {};
180
+ const depHits = Object.entries(dep).filter(([_, v]) => (v.depth_class || 0) > 0);
181
+ if (depHits.length) {
182
+ for (const [scen, v] of depHits) {
183
+ const lbl = scen.replace("dep_", "").replace(/_/g, " ");
184
+ rows.push({c: "hit", mark: "■", html: `Inside DEP ${lbl} — <strong>${v.depth_label}</strong>`});
185
+ }
186
+ } else {
187
+ rows.push({c: "miss", mark: "□", html: "Outside all DEP stormwater scenarios"});
188
+ }
189
+ const fn = ev.floodnet;
190
+ if (fn && fn.n_sensors) {
191
+ if (fn.n_flood_events_3y > 0) {
192
+ const peak = fn.peak_event;
193
+ const peakStr = peak && peak.max_depth_mm
194
+ ? `, peak <span class="gnum">${peak.max_depth_mm} mm</span>` : '';
195
+ rows.push({c: "hit", mark: "■",
196
+ html: `<span class="gnum">${fn.n_flood_events_3y}</span> FloodNet events (3 yr)${peakStr}`});
197
+ } else {
198
+ rows.push({c: "miss", mark: "□",
199
+ html: `<span class="gnum">${fn.n_sensors}</span> FloodNet sensor(s), no events`});
200
+ }
201
+ }
202
+ const ida = ev.ida_hwm;
203
+ if (ida && ida.n_within_radius > 0) {
204
+ const ht = ida.max_height_above_gnd_ft != null
205
+ ? `up to <span class="gnum">${ida.max_height_above_gnd_ft} ft</span> above ground` : '';
206
+ rows.push({c: "hit", mark: "■",
207
+ html: `<span class="gnum">${ida.n_within_radius}</span> Ida 2021 HWMs ≤${ida.radius_m} m${ht ? ', ' + ht : ''}`});
208
+ }
209
+ const mt = ev.microtopo;
210
+ if (mt) {
211
+ rows.push({c: "note", mark: "◆",
212
+ html: `Elevation <span class="gnum">${mt.point_elev_m} m</span>, lower than <span class="gnum">${mt.rel_elev_pct_200m}%</span> of nearby (200 m)`});
213
+ }
214
+ const c311 = ev.nyc311;
215
+ if (c311 && c311.n > 0) {
216
+ rows.push({c: "note", mark: "◆",
217
+ html: `<span class="gnum">${c311.n}</span> 311 flood complaints ≤${c311.radius_m} m, ${c311.years} yr`});
218
+ }
219
+ ul.innerHTML = rows
220
+ .map(r => `<li class="${r.c}"><span class="gmark">${r.mark}</span><span class="gtext">${r.html}</span></li>`).join("");
221
+ }
222
+
223
+ function renderSources(side, ev, paraText) {
224
+ const fired = new Set([...(paraText || "").matchAll(/\[([a-z0-9_]+)\]/g)].map(m => m[1]));
225
+ const order = [
226
+ "sandy", "dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current",
227
+ "floodnet", "ida_hwm", "microtopo", "nyc311",
228
+ "rag_dep_2013", "rag_nycha", "rag_coned", "rag_mta", "rag_comptroller",
229
+ ];
230
+ const present = new Set();
231
+ if (ev.sandy) present.add("sandy");
232
+ for (const [k, v] of Object.entries(ev.dep || {})) {
233
+ if ((v.depth_class || 0) > 0) present.add(k);
234
+ }
235
+ if (ev.floodnet && ev.floodnet.n_sensors > 0) present.add("floodnet");
236
+ if (ev.ida_hwm && ev.ida_hwm.n_within_radius > 0) present.add("ida_hwm");
237
+ if (ev.microtopo) present.add("microtopo");
238
+ if (ev.nyc311 && ev.nyc311.n > 0) present.add("nyc311");
239
+ if (ev.rag) for (const h of ev.rag) present.add(h.doc_id);
240
+
241
+ const ol = document.getElementById("sources" + side.toUpperCase());
242
+ ol.innerHTML = order.filter(d => present.has(d)).map(d => {
243
+ const label = SOURCE_LABELS[d] || d;
244
+ const dim = fired.has(d) ? "" : ' style="opacity:0.5"';
245
+ return `<li${dim}><span class="src-tag">${d}</span><span class="src-cite">${label}</span></li>`;
246
+ }).join("");
247
+ }
248
+
249
+ async function updateMapForSide(side, geo) {
250
+ ensureMap();
251
+ if (!map.loaded()) await new Promise(res => map.once("load", res));
252
+ const sideKey = side.toLowerCase();
253
+ map.getSource("addr_" + sideKey).setData({
254
+ type: "FeatureCollection",
255
+ features: [{ type: "Feature", geometry: { type: "Point", coordinates: [geo.lon, geo.lat] }, properties: {} }],
256
+ });
257
+ const url = (p) => `${p}?lat=${geo.lat}&lon=${geo.lon}&r=1500`;
258
+ const [sandy, dep, fn] = await Promise.all([
259
+ fetch(url("/api/layers/sandy")).then(r => r.json()).catch(() => null),
260
+ fetch(url("/api/layers/dep_extreme_2080")).then(r => r.json()).catch(() => null),
261
+ fetch(`/api/floodnet_near?lat=${geo.lat}&lon=${geo.lon}&r=1000`).then(r => r.json()).catch(() => null),
262
+ ]);
263
+ if (sandy) map.getSource("sandy_" + sideKey).setData(sandy);
264
+ if (dep) map.getSource("dep_" + sideKey).setData(dep);
265
+ if (fn) map.getSource("fn_" + sideKey).setData(fn);
266
+ }
267
+
268
+ function fitBoth(ga, gb) {
269
+ if (!ga || !gb || !map.loaded()) return;
270
+ const bounds = new maplibregl.LngLatBounds()
271
+ .extend([ga.lon, ga.lat]).extend([gb.lon, gb.lat]);
272
+ map.fitBounds(bounds, { padding: 80, duration: 800, maxZoom: 13 });
273
+ }
274
+
275
+ let geoA = null, geoB = null;
276
+
277
+ document.querySelectorAll(".chip[data-a]").forEach((btn) => {
278
+ btn.addEventListener("click", (e) => {
279
+ e.preventDefault();
280
+ document.getElementById("qa").value = btn.getAttribute("data-a");
281
+ document.getElementById("qb").value = btn.getAttribute("data-b");
282
+ document.getElementById("cform").requestSubmit();
283
+ });
284
+ });
285
+
286
+ document.getElementById("cform").addEventListener("submit", (e) => {
287
+ e.preventDefault();
288
+ const a = document.getElementById("qa").value.trim();
289
+ const b = document.getElementById("qb").value.trim();
290
+ if (!a || !b) return;
291
+ document.getElementById("aTitle").textContent = a;
292
+ document.getElementById("bTitle").textContent = b;
293
+ resetSide("a"); resetSide("b");
294
+ ensureMap();
295
+ geoA = geoB = null;
296
+ document.getElementById("cgo").disabled = true;
297
+ if (evtSrc) evtSrc.close();
298
+ evtSrc = new EventSource(`/api/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`);
299
+
300
+ evtSrc.addEventListener("step", (msg) => {
301
+ const ev = JSON.parse(msg.data);
302
+ markStep(ev.side, ev.step, ev);
303
+ });
304
+ evtSrc.addEventListener("final", (msg) => {
305
+ const ev = JSON.parse(msg.data);
306
+ const side = ev.side;
307
+ document.getElementById("report" + side.toUpperCase()).classList.remove("hidden");
308
+ if (ev.geocode) {
309
+ if (side === "a") geoA = ev.geocode; else geoB = ev.geocode;
310
+ updateMapForSide(side, ev.geocode).then(() => fitBoth(geoA, geoB));
311
+ }
312
+ if (ev.paragraph) renderParagraph(side, ev.paragraph);
313
+ renderGlance(side, ev);
314
+ renderSources(side, ev, ev.paragraph || "");
315
+ });
316
+ evtSrc.addEventListener("done", () => {
317
+ document.getElementById("cgo").disabled = false;
318
+ evtSrc.close();
319
+ });
320
+ evtSrc.addEventListener("error", () => {
321
+ document.getElementById("cgo").disabled = false;
322
+ });
323
+ });
web/static/register.html ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Riprap — NYC public schools flood exposure register</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
9
+ <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
10
+ </head>
11
+ <body class="register-mode">
12
+
13
+ <header class="topbar">
14
+ <div class="topbar-inner">
15
+ <div class="brand">
16
+ <span class="brand-name">Riprap</span>
17
+ <span class="brand-sep">·</span>
18
+ <span class="brand-tag">NYC public schools — flood exposure register</span>
19
+ </div>
20
+ <div class="topbar-right">
21
+ <a href="/" class="modelink">address</a>
22
+ <a href="/compare" class="modelink">compare</a>
23
+ <select id="classPicker" class="topbar-select">
24
+ <option value="schools">DOE schools</option>
25
+ <option value="nycha">NYCHA developments</option>
26
+ <option value="mta_entrances">MTA subway entrances</option>
27
+ </select>
28
+ <span class="local-pill" title="Pre-computed by the same FSM, bulk mode."><span class="dot"></span>bulk · pre-computed</span>
29
+ </div>
30
+ </div>
31
+ </header>
32
+
33
+ <div class="register-summary">
34
+ <div class="reg-stat"><span class="reg-stat-num" id="totalCount">—</span><span class="reg-stat-lbl">schools assessed</span></div>
35
+ <div class="reg-stat tier-1"><span class="reg-stat-num" id="tier1Count">—</span><span class="reg-stat-lbl">Tier 1</span></div>
36
+ <div class="reg-stat tier-2"><span class="reg-stat-num" id="tier2Count">—</span><span class="reg-stat-lbl">Tier 2</span></div>
37
+ <div class="reg-stat tier-3"><span class="reg-stat-num" id="tier3Count">—</span><span class="reg-stat-lbl">Tier 3</span></div>
38
+ <div class="reg-stat-spacer"></div>
39
+ <div class="reg-controls">
40
+ <input type="text" id="filter" placeholder="filter by name, address, or borough…" />
41
+ <select id="boroughFilter">
42
+ <option value="">All boroughs</option>
43
+ <option>Manhattan</option><option>Bronx</option>
44
+ <option>Brooklyn</option><option>Queens</option><option>Staten Island</option>
45
+ </select>
46
+ <button id="exportCsv" class="btn-secondary" type="button">CSV</button>
47
+ <button id="exportGeojson" class="btn-secondary" type="button">GeoJSON</button>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="register-workbench">
52
+ <section class="reg-table-wrap panel">
53
+ <h2>Ranked by exposure score<span class="hint">click any row for details</span></h2>
54
+ <div class="reg-tablescroll">
55
+ <table id="regTable">
56
+ <thead>
57
+ <tr>
58
+ <th>Tier</th>
59
+ <th>Score</th>
60
+ <th>School</th>
61
+ <th>Borough</th>
62
+ <th class="num">Sandy</th>
63
+ <th class="num">DEP&nbsp;2080</th>
64
+ <th class="num">311</th>
65
+ <th class="num">FloodNet</th>
66
+ <th class="num">Ida</th>
67
+ </tr>
68
+ </thead>
69
+ <tbody id="regBody"></tbody>
70
+ </table>
71
+ </div>
72
+ </section>
73
+
74
+ <aside class="reg-detail panel" id="regDetail">
75
+ <h2>Detail<span class="hint">select a school</span></h2>
76
+ <div class="reg-detail-empty" id="detailEmpty">
77
+ Select a row to load the cited per-asset report.
78
+ </div>
79
+ <div class="reg-detail-body hidden" id="detailBody">
80
+ <div id="detailHeader" class="reg-detail-header"></div>
81
+ <div id="detailMap"></div>
82
+ <div class="report-section"><h3>At a glance</h3><ul id="detailGlance" class="glance-list"></ul></div>
83
+ <div class="report-section"><h3>Summary</h3><p id="detailParagraph"></p>
84
+ <div id="detailNoPara" class="hint" style="margin-top: 8px;">
85
+ Tier-3 paragraphs are computed on demand. <button id="livePara" type="button" class="btn-secondary">Generate cited paragraph (~10 s)</button>
86
+ </div>
87
+ </div>
88
+ <div class="report-section"><h3>Sources fired</h3><ol id="detailSources" class="sources-list"></ol></div>
89
+ </div>
90
+ </aside>
91
+ </div>
92
+
93
+ <footer>
94
+ <div class="foot-inner">
95
+ <div class="foot-col">
96
+ <h3>Bulk mode</h3>
97
+ <p>
98
+ The same 8-specialist FSM that answers a single address is run
99
+ in batch over every NYC public school (1,992 from the DOE
100
+ location dataset). Each row is scored on a transparent rubric —
101
+ Sandy 2012 inundation, DEP stormwater scenarios, FloodNet sensor
102
+ history, USGS Ida 2021 high-water marks, LiDAR micro-topography,
103
+ NYC 311 flood complaints. Tier-1 and Tier-2 schools have their
104
+ full Granite-reconciled cited paragraph pre-computed; Tier-3
105
+ paragraphs are generated on click.
106
+ </p>
107
+ </div>
108
+ <div class="foot-col">
109
+ <h3>Source</h3>
110
+ <p>
111
+ NYC DOE 2019-2020 School Point Locations
112
+ (<a href="https://data.cityofnewyork.us/Education/2019-2020-School-Point-Locations/a3nt-yts4" target="_blank">a3nt-yts4</a>).
113
+ Same data substrate as the address mode.
114
+ </p>
115
+ </div>
116
+ </div>
117
+ </footer>
118
+
119
+ <script src="/static/register.js"></script>
120
+ </body>
121
+ </html>
web/static/register.js ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Riprap — Bulk mode (schools register).
2
+
3
+ const SOURCE_LABELS = {
4
+ geocode: "NYC DCP Geosearch",
5
+ sandy: "NYC OpenData 5xsi-dfpx — Sandy 2012 inundation",
6
+ dep_extreme_2080: "NYC DEP Stormwater — Extreme 3.66 in/hr + 2080 SLR",
7
+ dep_moderate_2050: "NYC DEP Stormwater — Moderate 2.13 in/hr + 2050 SLR",
8
+ dep_moderate_current: "NYC DEP Stormwater — Moderate 2.13 in/hr current",
9
+ floodnet: "FloodNet NYC — live ultrasonic sensor network",
10
+ nyc311: "NYC 311 (Socrata erm2-nwe9) — flood descriptors",
11
+ microtopo: "USGS 3DEP 30 m DEM via py3dep",
12
+ ida_hwm: "USGS STN — Hurricane Ida 2021 HWMs (Event 312, NY)",
13
+ prithvi_water: "Prithvi-EO 2.0 (300M, NASA/IBM) Sen1Floods11 — satellite water segmentation",
14
+ rag_dep_2013: "NYC DEP Wastewater Resiliency Plan (2013)",
15
+ rag_nycha: "NYCHA — Flood Resilience: Lessons Learned",
16
+ rag_coned: "Con Edison Climate Change Resilience Plan (Case 22-E-0222)",
17
+ rag_mta: "MTA Climate Resilience Roadmap (Oct 2025)",
18
+ rag_comptroller: "NYC Comptroller — \"Is NYC Ready for Rain?\" (2024)",
19
+ };
20
+
21
+ const $ = (s) => document.querySelector(s);
22
+
23
+ let allRows = [];
24
+ let filteredRows = [];
25
+ let selected = null;
26
+ let detailMap = null;
27
+
28
+ function tierBadge(t) {
29
+ const cls = "tier-badge tier-" + t;
30
+ return `<span class="${cls}">${t}</span>`;
31
+ }
32
+
33
+ function yn(b) { return b ? '<span class="yn yes">●</span>' : '<span class="yn no">○</span>'; }
34
+
35
+ function renderTable() {
36
+ const tbody = $("#regBody");
37
+ tbody.innerHTML = filteredRows.map((r, i) => {
38
+ const snap = r.snap || {};
39
+ const sandy = snap.sandy ? yn(true) : yn(false);
40
+ const dep80 = snap.dep && snap.dep.dep_extreme_2080 && snap.dep.dep_extreme_2080.depth_class > 0 ? yn(true) : yn(false);
41
+ const c311 = (snap.nyc311 && snap.nyc311.n) || 0;
42
+ const fnEv = (snap.floodnet && snap.floodnet.n_flood_events_3y) || 0;
43
+ const idaN = (snap.ida_hwm && snap.ida_hwm.n_within_radius) || 0;
44
+ return `<tr data-idx="${i}">
45
+ <td>${tierBadge(r.tier)}</td>
46
+ <td class="num">${r.score}</td>
47
+ <td>
48
+ <div class="rname">${r.name}</div>
49
+ <div class="raddr">${r.address || ""}</div>
50
+ </td>
51
+ <td>${r.borough || ""}</td>
52
+ <td class="num">${sandy}</td>
53
+ <td class="num">${dep80}</td>
54
+ <td class="num">${c311 || ""}</td>
55
+ <td class="num">${fnEv || ""}</td>
56
+ <td class="num">${idaN || ""}</td>
57
+ </tr>`;
58
+ }).join("");
59
+
60
+ tbody.querySelectorAll("tr").forEach((tr) => {
61
+ tr.addEventListener("click", () => selectRow(parseInt(tr.dataset.idx, 10)));
62
+ });
63
+ }
64
+
65
+ function applyFilters() {
66
+ const q = ($("#filter").value || "").toLowerCase();
67
+ const boro = $("#boroughFilter").value || "";
68
+ filteredRows = allRows.filter((r) => {
69
+ if (boro && r.borough !== boro) return false;
70
+ if (!q) return true;
71
+ return (
72
+ (r.name || "").toLowerCase().includes(q) ||
73
+ (r.address || "").toLowerCase().includes(q) ||
74
+ (r.borough || "").toLowerCase().includes(q) ||
75
+ (r.bbl || "").toString().includes(q)
76
+ );
77
+ });
78
+ renderTable();
79
+ }
80
+
81
+ function selectRow(idx) {
82
+ selected = filteredRows[idx];
83
+ if (!selected) return;
84
+ $("#detailEmpty").classList.add("hidden");
85
+ $("#detailBody").classList.remove("hidden");
86
+ $("#detailHeader").innerHTML = `
87
+ <div class="rname">${selected.name}</div>
88
+ <div class="raddr">${selected.address}, ${selected.borough}</div>
89
+ <div class="rmeta">BBL: ${selected.bbl || "—"} · Tier ${selected.tier} · Score ${selected.score}</div>`;
90
+ renderDetail(selected);
91
+ }
92
+
93
+ function renderDetail(row) {
94
+ const snap = row.snap || {};
95
+ // glance
96
+ const rows = [];
97
+ if (snap.sandy) rows.push({c:"hit", mark:"■", html:"Inside <strong>Sandy 2012</strong> inundation extent"});
98
+ else rows.push({c:"miss",mark:"□", html:"Outside Sandy 2012 inundation extent"});
99
+ const dep = snap.dep || {};
100
+ const depHits = Object.entries(dep).filter(([_,v]) => (v.depth_class || 0) > 0);
101
+ if (depHits.length) {
102
+ for (const [scen, v] of depHits) {
103
+ const lbl = scen.replace("dep_","").replace(/_/g, " ");
104
+ rows.push({c:"hit", mark:"■", html:`Inside DEP ${lbl} — <strong>${v.depth_label}</strong>`});
105
+ }
106
+ } else {
107
+ rows.push({c:"miss", mark:"□", html:"Outside all DEP stormwater scenarios"});
108
+ }
109
+ const fn = snap.floodnet;
110
+ if (fn && fn.n_sensors) {
111
+ if (fn.n_flood_events_3y > 0) {
112
+ const peak = fn.peak_event;
113
+ const peakStr = peak && peak.max_depth_mm ? `, peak <span class="gnum">${peak.max_depth_mm} mm</span>` : "";
114
+ rows.push({c:"hit", mark:"■", html:`<span class="gnum">${fn.n_flood_events_3y}</span> FloodNet events (3 yr)${peakStr}`});
115
+ }
116
+ }
117
+ const ida = snap.ida_hwm;
118
+ if (ida && ida.n_within_radius > 0) {
119
+ const ht = ida.max_height_above_gnd_ft != null ? `up to <span class="gnum">${ida.max_height_above_gnd_ft} ft</span> above ground` : "";
120
+ rows.push({c:"hit", mark:"■", html:`<span class="gnum">${ida.n_within_radius}</span> Ida HWMs ≤${ida.radius_m} m${ht ? ", " + ht : ""}`});
121
+ }
122
+ const mt = snap.microtopo;
123
+ if (mt) {
124
+ rows.push({c:"note", mark:"◆", html:`Elevation <span class="gnum">${mt.point_elev_m} m</span>; lower than <span class="gnum">${mt.rel_elev_pct_200m}%</span> of nearby (200 m)`});
125
+ }
126
+ const c311 = snap.nyc311;
127
+ if (c311 && c311.n > 0) {
128
+ rows.push({c:"note", mark:"◆", html:`<span class="gnum">${c311.n}</span> 311 flood complaints ≤${c311.radius_m} m, ${c311.years} yr`});
129
+ }
130
+ $("#detailGlance").innerHTML = rows.map(r =>
131
+ `<li class="${r.c}"><span class="gmark">${r.mark}</span><span class="gtext">${r.html}</span></li>`).join("");
132
+
133
+ // paragraph
134
+ const para = snap.paragraph;
135
+ const noPara = $("#detailNoPara");
136
+ if (para) {
137
+ $("#detailParagraph").innerHTML = (para || "").replace(/\[([a-z0-9_]+)\]/gi,
138
+ (_, d) => `<span class="cite" title="document id: ${d}">[${d}]</span>`);
139
+ noPara.classList.add("hidden");
140
+ } else {
141
+ $("#detailParagraph").innerHTML = "";
142
+ noPara.classList.remove("hidden");
143
+ }
144
+
145
+ // sources
146
+ const fired = new Set([...(para || "").matchAll(/\[([a-z0-9_]+)\]/g)].map(m => m[1]));
147
+ const order = [
148
+ "sandy", "dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current",
149
+ "floodnet", "ida_hwm", "microtopo", "nyc311",
150
+ "rag_dep_2013", "rag_nycha", "rag_coned", "rag_mta", "rag_comptroller",
151
+ ];
152
+ const present = new Set();
153
+ if (snap.sandy) present.add("sandy");
154
+ for (const [k, v] of Object.entries(snap.dep || {})) {
155
+ if ((v.depth_class || 0) > 0) present.add(k);
156
+ }
157
+ if (snap.floodnet && snap.floodnet.n_sensors > 0) present.add("floodnet");
158
+ if (snap.ida_hwm && snap.ida_hwm.n_within_radius > 0) present.add("ida_hwm");
159
+ if (snap.microtopo) present.add("microtopo");
160
+ if (snap.nyc311 && snap.nyc311.n > 0) present.add("nyc311");
161
+ if (snap.rag) for (const h of snap.rag) present.add(h.doc_id);
162
+
163
+ $("#detailSources").innerHTML = order.filter(d => present.has(d)).map(d => {
164
+ const dim = fired.has(d) ? "" : ' style="opacity:0.5"';
165
+ return `<li${dim}><span class="src-tag">${d}</span><span class="src-cite">${SOURCE_LABELS[d] || d}</span></li>`;
166
+ }).join("");
167
+
168
+ showDetailMap(row);
169
+ }
170
+
171
+ function showDetailMap(row) {
172
+ const div = $("#detailMap");
173
+ if (!detailMap) {
174
+ detailMap = new maplibregl.Map({
175
+ container: "detailMap",
176
+ style: {
177
+ version: 8,
178
+ sources: {
179
+ carto: { type: "raster",
180
+ tiles: ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"],
181
+ tileSize: 256, attribution: "© OSM · CARTO" },
182
+ },
183
+ layers: [{ id: "bg", type: "raster", source: "carto" }],
184
+ },
185
+ center: [row.lon, row.lat], zoom: 14, attributionControl: { compact: true },
186
+ });
187
+ detailMap.on("load", () => {
188
+ detailMap.addSource("addr", { type: "geojson", data: { type:"FeatureCollection", features:[] }});
189
+ detailMap.addLayer({ id:"addr-marker", type:"circle", source:"addr",
190
+ paint: { "circle-radius": 9, "circle-color": "#1642DF", "circle-stroke-color":"#fff", "circle-stroke-width": 2.5 }});
191
+ });
192
+ }
193
+ const setMarker = () => {
194
+ detailMap.getSource("addr").setData({ type:"FeatureCollection", features:[
195
+ { type:"Feature", geometry:{ type:"Point", coordinates:[row.lon, row.lat] }, properties:{} }
196
+ ]});
197
+ detailMap.flyTo({ center: [row.lon, row.lat], zoom: 14 });
198
+ };
199
+ if (detailMap.loaded()) setMarker(); else detailMap.once("load", setMarker);
200
+ }
201
+
202
+ async function generateLiveParagraph() {
203
+ if (!selected) return;
204
+ const btn = $("#livePara"); btn.disabled = true; btn.textContent = "Generating…";
205
+ try {
206
+ const u = `/api/stream?q=${encodeURIComponent(selected.address + ", " + selected.borough)}`;
207
+ // collect SSE final event
208
+ const text = await new Promise((resolve, reject) => {
209
+ const es = new EventSource(u);
210
+ es.addEventListener("final", (m) => { resolve(JSON.parse(m.data)); es.close(); });
211
+ es.addEventListener("error", () => { reject(new Error("stream error")); es.close(); });
212
+ setTimeout(() => { reject(new Error("timeout")); es.close(); }, 90000);
213
+ });
214
+ if (text.paragraph) {
215
+ selected.snap.paragraph = text.paragraph;
216
+ selected.snap.rag = text.rag || selected.snap.rag;
217
+ renderDetail(selected);
218
+ }
219
+ } catch (e) {
220
+ btn.textContent = "Failed: " + e.message;
221
+ btn.disabled = false;
222
+ }
223
+ }
224
+
225
+ function exportCsv() {
226
+ const cols = ["tier","score","name","address","borough","bbl","bin",
227
+ "lat","lon","sandy","dep_extreme_2080","floodnet_events_3y",
228
+ "ida_hwms_800m","nyc311_5y"];
229
+ const lines = [cols.join(",")];
230
+ for (const r of filteredRows) {
231
+ const s = r.snap || {};
232
+ const row = [
233
+ r.tier, r.score, JSON.stringify(r.name), JSON.stringify(r.address || ""),
234
+ r.borough || "", r.bbl || "", r.bin || "", r.lat, r.lon,
235
+ s.sandy ? 1 : 0,
236
+ (s.dep && s.dep.dep_extreme_2080 && s.dep.dep_extreme_2080.depth_class) || 0,
237
+ (s.floodnet && s.floodnet.n_flood_events_3y) || 0,
238
+ (s.ida_hwm && s.ida_hwm.n_within_radius) || 0,
239
+ (s.nyc311 && s.nyc311.n) || 0,
240
+ ];
241
+ lines.push(row.join(","));
242
+ }
243
+ const blob = new Blob([lines.join("\n")], { type: "text/csv" });
244
+ download(blob, "riprap_schools_register.csv");
245
+ }
246
+
247
+ function exportGeojson() {
248
+ const features = filteredRows.map((r) => ({
249
+ type: "Feature",
250
+ geometry: { type: "Point", coordinates: [r.lon, r.lat] },
251
+ properties: {
252
+ tier: r.tier, score: r.score, name: r.name, address: r.address,
253
+ borough: r.borough, bbl: r.bbl, bin: r.bin,
254
+ sandy: !!(r.snap && r.snap.sandy),
255
+ dep_extreme_2080: (r.snap && r.snap.dep && r.snap.dep.dep_extreme_2080 && r.snap.dep.dep_extreme_2080.depth_class) || 0,
256
+ },
257
+ }));
258
+ const blob = new Blob([JSON.stringify({ type: "FeatureCollection", features })],
259
+ { type: "application/geo+json" });
260
+ download(blob, "riprap_schools_register.geojson");
261
+ }
262
+
263
+ function download(blob, filename) {
264
+ const u = URL.createObjectURL(blob);
265
+ const a = document.createElement("a");
266
+ a.href = u; a.download = filename;
267
+ document.body.appendChild(a); a.click();
268
+ setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(u); }, 200);
269
+ }
270
+
271
+ // Asset class is parsed from URL path: /register/<asset_class>
272
+ const ASSET_CLASS = (location.pathname.match(/\/register\/([^\/?]+)/) || [])[1] || "schools";
273
+
274
+ const ASSET_TITLES = {
275
+ schools: "NYC public schools — flood exposure register",
276
+ nycha: "NYCHA developments — flood exposure register",
277
+ mta_entrances: "MTA subway entrances — flood exposure register",
278
+ };
279
+ document.title = `Riprap — ${ASSET_TITLES[ASSET_CLASS] || ASSET_CLASS}`;
280
+ const tagSpan = document.querySelector(".brand-tag");
281
+ if (tagSpan) tagSpan.textContent = ASSET_TITLES[ASSET_CLASS] || ASSET_CLASS;
282
+
283
+ const classPicker = document.getElementById("classPicker");
284
+ if (classPicker) {
285
+ classPicker.value = ASSET_CLASS;
286
+ classPicker.addEventListener("change", () => {
287
+ location.href = `/register/${classPicker.value}`;
288
+ });
289
+ }
290
+
291
+ (async function init() {
292
+ const r = await fetch(`/api/register/${ASSET_CLASS}`);
293
+ if (!r.ok) {
294
+ const script = `python scripts/build_${ASSET_CLASS}_register.py`;
295
+ $("#regBody").innerHTML = `<tr><td colspan="9" style="padding: 20px; color: var(--text-muted);">Register not built. Run <code>${script}</code>.</td></tr>`;
296
+ return;
297
+ }
298
+ const data = await r.json();
299
+ allRows = data.rows || [];
300
+ filteredRows = allRows.slice();
301
+
302
+ // tier counts
303
+ $("#totalCount").textContent = allRows.length;
304
+ $("#tier1Count").textContent = allRows.filter(r => r.tier === 1).length;
305
+ $("#tier2Count").textContent = allRows.filter(r => r.tier === 2).length;
306
+ $("#tier3Count").textContent = allRows.filter(r => r.tier === 3).length;
307
+
308
+ renderTable();
309
+
310
+ $("#filter").addEventListener("input", applyFilters);
311
+ $("#boroughFilter").addEventListener("change", applyFilters);
312
+ $("#exportCsv").addEventListener("click", exportCsv);
313
+ $("#exportGeojson").addEventListener("click", exportGeojson);
314
+ $("#livePara").addEventListener("click", generateLiveParagraph);
315
+ })();