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

FastAPI primary UI + NYCO design system vendor drop

Browse files

web/main.py wires the address-mode endpoint at /, dispatches to the
Burr FSM, and returns the reconciled paragraph + per-step trace as
JSON for the front-end to render. /compare and /register/{class}
routes are stubbed for the next slot.

The static UI follows NYC Planning Labs' civic-assessment idiom
(ZoLa, Capital Planning Explorer): clean two-column layout, IBM
Plex Sans throughout, MapLibre for the map, vanilla JS event flow
with no framework. NYCO design-system CSS + fonts ship as a vendor
drop so the build stays Node-free.

web/__init__.py ADDED
File without changes
web/main.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HeliOS-NYC web UI — FastAPI + SSE streaming of the Burr FSM trace.
2
+
3
+ Run: uvicorn web.main:app --reload --port 8000
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import warnings
9
+ from pathlib import Path
10
+
11
+ warnings.filterwarnings("ignore")
12
+
13
+ from fastapi import FastAPI, Request # noqa: E402
14
+ from fastapi.responses import FileResponse, StreamingResponse # noqa: E402
15
+ from fastapi.staticfiles import StaticFiles # noqa: E402
16
+
17
+ from app.context import floodnet # noqa: E402
18
+ from app.flood_layers import dep_stormwater, sandy_inundation # noqa: E402
19
+ from app.fsm import iter_steps # noqa: E402
20
+
21
+ ROOT = Path(__file__).resolve().parent
22
+ STATIC = ROOT / "static"
23
+
24
+ app = FastAPI(title="Riprap")
25
+ app.mount("/static", StaticFiles(directory=STATIC), name="static")
26
+
27
+ import json as _json # noqa: E402
28
+
29
+ import geopandas as _gpd # noqa: E402
30
+ from fastapi.responses import JSONResponse, Response # noqa: E402
31
+
32
+ _LAYER_CACHE: dict = {}
33
+
34
+
35
+ def _clip_simplify(gdf, lat: float, lon: float, radius_m: float = 1500,
36
+ simplify_ft: float = 8, props_keep=None):
37
+ """Clip a NYC-wide layer to a small bbox around a point and simplify.
38
+
39
+ Uses shapely's clip_by_rect (much faster than gpd.overlay on dense
40
+ polygons) and a pre-bbox-filter via .cx so we never touch geometries
41
+ outside the AOI.
42
+ """
43
+ import shapely.geometry as sg
44
+
45
+ pt = _gpd.GeoSeries([sg.Point(lon, lat)], crs="EPSG:4326").to_crs("EPSG:2263")[0]
46
+ half = radius_m * 3.281
47
+ minx, miny, maxx, maxy = pt.x - half, pt.y - half, pt.x + half, pt.y + half
48
+
49
+ sub = gdf.cx[minx:maxx, miny:maxy]
50
+ if sub.empty:
51
+ return {"type": "FeatureCollection", "features": []}
52
+
53
+ clipped = sub.copy()
54
+ clipped["geometry"] = sub.geometry.clip_by_rect(minx, miny, maxx, maxy)
55
+ clipped = clipped[~clipped.geometry.is_empty & clipped.geometry.notna()]
56
+ if clipped.empty:
57
+ return {"type": "FeatureCollection", "features": []}
58
+
59
+ clipped["geometry"] = clipped.geometry.simplify(simplify_ft, preserve_topology=True)
60
+ g = clipped.to_crs("EPSG:4326")
61
+ if props_keep is not None:
62
+ g = g[[c for c in g.columns if c in props_keep or c == "geometry"]]
63
+ else:
64
+ g = g[["geometry"]]
65
+ return _json.loads(g.to_json())
66
+
67
+
68
+ @app.on_event("startup")
69
+ def _warm_caches():
70
+ """Prime slow loads so the first user query doesn't pay the cold-cost penalty."""
71
+ print("[startup] warming flood layers...", flush=True)
72
+ sandy_inundation.load()
73
+ for scen in ["dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current"]:
74
+ dep_stormwater.load(scen)
75
+ print("[startup] flood layers ready", flush=True)
76
+ print("[startup] warming RAG (Granite Embedding 278M + 5 PDFs)...", flush=True)
77
+ from app import rag
78
+ rag.warm()
79
+ print("[startup] RAG ready", flush=True)
80
+
81
+
82
+ @app.get("/")
83
+ def index():
84
+ return FileResponse(STATIC / "index.html")
85
+
86
+
87
+ @app.get("/compare")
88
+ def compare_page():
89
+ return FileResponse(STATIC / "compare.html")
90
+
91
+
92
+ @app.get("/register/{asset_class}")
93
+ def register_page(asset_class: str):
94
+ if asset_class not in ("schools", "nycha", "mta_entrances"):
95
+ return JSONResponse({"error": f"unknown asset class {asset_class!r}"}, status_code=404)
96
+ return FileResponse(STATIC / "register.html")
97
+
98
+
99
+ @app.get("/api/register/{asset_class}")
100
+ def api_register(asset_class: str):
101
+ """Return a pre-computed asset-class register."""
102
+ if asset_class not in ("schools", "nycha", "mta_entrances"):
103
+ return JSONResponse({"error": f"unknown asset class {asset_class!r}"},
104
+ status_code=404)
105
+ f = ROOT.parent / "data" / "registers" / f"{asset_class}.json"
106
+ if not f.exists():
107
+ script = f"scripts/build_{asset_class}_register.py"
108
+ return JSONResponse(
109
+ {"error": f"register not built — run python {script}",
110
+ "rows": []},
111
+ status_code=503,
112
+ )
113
+ return JSONResponse(_json.loads(f.read_text()),
114
+ headers={"Cache-Control": "public, max-age=300"})
115
+
116
+
117
+ @app.get("/api/compare")
118
+ async def compare_stream(a: str, b: str, request: Request):
119
+ """Two parallel FSM runs, results returned as a single SSE stream.
120
+ Each event is tagged with side="a" or side="b" so the client can
121
+ route updates to the correct panel."""
122
+ import asyncio
123
+ import queue
124
+ from app.fsm import iter_steps
125
+
126
+ def gen_for_side(side: str, q_text: str, out_q):
127
+ try:
128
+ for ev in iter_steps(q_text):
129
+ ev["side"] = side
130
+ out_q.put(ev)
131
+ except Exception as e:
132
+ out_q.put({"side": side, "kind": "error", "err": str(e)})
133
+ out_q.put({"side": side, "kind": "_done"})
134
+
135
+ out_q: "queue.Queue[dict]" = queue.Queue()
136
+
137
+ def kick():
138
+ # run both sides in parallel threads — each Burr Application owns
139
+ # its own state so this is safe, and Ollama with NUM_PARALLEL=2
140
+ # serves both reconcile calls concurrently.
141
+ loop = asyncio.get_event_loop()
142
+ loop.run_in_executor(None, gen_for_side, "a", a, out_q)
143
+ loop.run_in_executor(None, gen_for_side, "b", b, out_q)
144
+
145
+ async def event_stream():
146
+ kick()
147
+ yield f"event: hello\ndata: {json.dumps({'a': a, 'b': b})}\n\n"
148
+ done = 0
149
+ while done < 2:
150
+ try:
151
+ ev = await asyncio.to_thread(out_q.get, True, 1.0)
152
+ except Exception:
153
+ continue
154
+ if ev.get("kind") == "_done":
155
+ done += 1
156
+ continue
157
+ if ev.get("kind") == "step":
158
+ yield f"event: step\ndata: {json.dumps(ev, default=str)}\n\n"
159
+ elif ev.get("kind") == "final":
160
+ yield f"event: final\ndata: {json.dumps(ev, default=str)}\n\n"
161
+ elif ev.get("kind") == "error":
162
+ yield f"event: error\ndata: {json.dumps(ev)}\n\n"
163
+ yield "event: done\ndata: {}\n\n"
164
+
165
+ return StreamingResponse(event_stream(), media_type="text/event-stream",
166
+ headers={"Cache-Control": "no-cache",
167
+ "X-Accel-Buffering": "no"})
168
+
169
+
170
+ @app.get("/api/stream")
171
+ async def stream(q: str, request: Request):
172
+ """Server-sent-events stream: each FSM action yields one event."""
173
+ def gen():
174
+ try:
175
+ yield f"event: hello\ndata: {json.dumps({'query': q})}\n\n"
176
+ for ev in iter_steps(q):
177
+ if ev["kind"] == "step":
178
+ yield f"event: step\ndata: {json.dumps(ev, default=str)}\n\n"
179
+ else:
180
+ yield f"event: final\ndata: {json.dumps(ev, default=str)}\n\n"
181
+ yield "event: done\ndata: {}\n\n"
182
+ except Exception as e:
183
+ yield f"event: error\ndata: {json.dumps({'err': str(e)})}\n\n"
184
+
185
+ return StreamingResponse(gen(), media_type="text/event-stream",
186
+ headers={"Cache-Control": "no-cache",
187
+ "X-Accel-Buffering": "no"})
188
+
189
+
190
+ @app.get("/api/layers/sandy")
191
+ def layer_sandy(lat: float, lon: float, r: float = 1500):
192
+ key = ("sandy", round(lat, 4), round(lon, 4), int(r))
193
+ if key not in _LAYER_CACHE:
194
+ _LAYER_CACHE[key] = _clip_simplify(sandy_inundation.load(), lat, lon, r)
195
+ return JSONResponse(_LAYER_CACHE[key],
196
+ headers={"Cache-Control": "public, max-age=3600"})
197
+
198
+
199
+ @app.get("/api/layers/dep_extreme_2080")
200
+ def layer_dep_2080(lat: float, lon: float, r: float = 1500):
201
+ key = ("dep2080", round(lat, 4), round(lon, 4), int(r))
202
+ if key not in _LAYER_CACHE:
203
+ _LAYER_CACHE[key] = _clip_simplify(
204
+ dep_stormwater.load("dep_extreme_2080"),
205
+ lat, lon, r, props_keep={"Flooding_Category"})
206
+ return JSONResponse(_LAYER_CACHE[key],
207
+ headers={"Cache-Control": "public, max-age=3600"})
208
+
209
+
210
+ @app.get("/api/layers/prithvi_water")
211
+ def layer_prithvi_water(lat: float, lon: float, r: float = 1500):
212
+ """Prithvi-EO 2.0 (Sen1Floods11) satellite water mask, clipped to a
213
+ bbox around the address for performance."""
214
+ key = ("prithvi", round(lat, 4), round(lon, 4), int(r))
215
+ if key not in _LAYER_CACHE:
216
+ from app.flood_layers import prithvi_water as pw
217
+ gdf, _meta = pw._load()
218
+ if gdf is None:
219
+ return JSONResponse({"type": "FeatureCollection", "features": []})
220
+ _LAYER_CACHE[key] = _clip_simplify(gdf, lat, lon, r,
221
+ props_keep=set(),
222
+ simplify_ft=4)
223
+ return JSONResponse(_LAYER_CACHE[key],
224
+ headers={"Cache-Control": "public, max-age=3600"})
225
+
226
+
227
+ @app.get("/api/floodnet_near")
228
+ def floodnet_near(lat: float, lon: float, r: float = 1000):
229
+ sensors = floodnet.sensors_near(lat, lon, r)
230
+ ids = [s.deployment_id for s in sensors]
231
+ events = floodnet.flood_events_for(ids)
232
+ by_dep: dict = {}
233
+ for e in events:
234
+ by_dep.setdefault(e.deployment_id, []).append(e)
235
+
236
+ features = []
237
+ for s in sensors:
238
+ if s.lat is None or s.lon is None:
239
+ continue
240
+ evs = by_dep.get(s.deployment_id, [])
241
+ peak = max((e.max_depth_mm or 0 for e in evs), default=0)
242
+ features.append({
243
+ "type": "Feature",
244
+ "geometry": {"type": "Point", "coordinates": [s.lon, s.lat]},
245
+ "properties": {
246
+ "deployment_id": s.deployment_id,
247
+ "name": s.name,
248
+ "street": s.street,
249
+ "borough": s.borough,
250
+ "n_events_3y": len(evs),
251
+ "peak_depth_mm": peak,
252
+ },
253
+ })
254
+ return JSONResponse({"type": "FeatureCollection", "features": features})
web/static/app.js ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // HeliOS-NYC web client. Subscribes to SSE, lights up FSM steps.
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-workflows 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 $ = (s) => document.querySelector(s);
23
+
24
+ let evtSrc = null;
25
+ let map = null;
26
+ let mapInit = false;
27
+
28
+ const MAP_STYLE = {
29
+ version: 8,
30
+ sources: {
31
+ carto: {
32
+ type: "raster",
33
+ tiles: ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"],
34
+ tileSize: 256,
35
+ attribution: "© OpenStreetMap contributors © CARTO",
36
+ },
37
+ },
38
+ layers: [
39
+ { id: "bg", type: "background", paint: { "background-color": "#fafbfd" } },
40
+ { id: "carto", type: "raster", source: "carto" },
41
+ ],
42
+ };
43
+
44
+ function ensureMap() {
45
+ if (mapInit) return;
46
+ mapInit = true;
47
+ map = new maplibregl.Map({
48
+ container: "map",
49
+ style: MAP_STYLE,
50
+ center: [-74.0, 40.72],
51
+ zoom: 10,
52
+ attributionControl: { compact: true },
53
+ });
54
+ map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "top-right");
55
+
56
+ map.on("load", async () => {
57
+ // Sandy + DEP layers — empty until first query (we clip per-address)
58
+ map.addSource("sandy", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
59
+ map.addLayer({
60
+ id: "sandy-fill", type: "fill", source: "sandy",
61
+ paint: { "fill-color": "#fc5d52", "fill-opacity": 0.28 },
62
+ });
63
+ map.addLayer({
64
+ id: "sandy-line", type: "line", source: "sandy",
65
+ paint: { "line-color": "#fc5d52", "line-width": 0.6, "line-opacity": 0.6 },
66
+ });
67
+
68
+ map.addSource("dep", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
69
+ map.addLayer({
70
+ id: "dep-fill", type: "fill", source: "dep",
71
+ paint: {
72
+ "fill-color": [
73
+ "match", ["get", "Flooding_Category"],
74
+ 1, "#568adf", 2, "#1642DF", 3, "#031553", "#568adf",
75
+ ],
76
+ "fill-opacity": 0.32,
77
+ },
78
+ });
79
+
80
+ // Prithvi-EO 2.0 satellite water polygons. Visually distinct from the
81
+ // modeled DEP/Sandy layers — teal outline + low fill says "what the
82
+ // satellite saw" not "what FEMA/DEP modeled".
83
+ map.addSource("prithvi", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
84
+ map.addLayer({
85
+ id: "prithvi-fill", type: "fill", source: "prithvi",
86
+ paint: { "fill-color": "#0d9488", "fill-opacity": 0.18 },
87
+ });
88
+ map.addLayer({
89
+ id: "prithvi-line", type: "line", source: "prithvi",
90
+ paint: { "line-color": "#0d9488", "line-width": 1.2, "line-opacity": 0.85 },
91
+ });
92
+
93
+ // empty floodnet + addr sources, populated per query
94
+ map.addSource("floodnet", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
95
+ map.addLayer({
96
+ id: "floodnet-circles", type: "circle", source: "floodnet",
97
+ paint: {
98
+ "circle-radius": 6,
99
+ "circle-color": ["case", [">", ["get", "n_events_3y"], 0], "#fc5d52", "#1a8754"],
100
+ "circle-stroke-color": "#ffffff",
101
+ "circle-stroke-width": 1.8,
102
+ },
103
+ });
104
+ map.on("click", "floodnet-circles", (e) => {
105
+ const f = e.features[0];
106
+ const p = f.properties;
107
+ new maplibregl.Popup()
108
+ .setLngLat(f.geometry.coordinates)
109
+ .setHTML(`<b>${p.name}</b><br>${p.street}<br>events 3y: ${p.n_events_3y}<br>peak: ${p.peak_depth_mm} mm`)
110
+ .addTo(map);
111
+ });
112
+
113
+ map.addSource("addr", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
114
+ map.addLayer({
115
+ id: "addr-marker", type: "circle", source: "addr",
116
+ paint: {
117
+ "circle-radius": 9,
118
+ "circle-color": "#1642DF",
119
+ "circle-stroke-color": "#ffffff",
120
+ "circle-stroke-width": 2.5,
121
+ },
122
+ });
123
+ });
124
+ }
125
+
126
+ async function updateMapForResult(geo) {
127
+ ensureMap();
128
+ if (!map.loaded()) {
129
+ await new Promise(res => map.once("load", res));
130
+ }
131
+ // address marker
132
+ map.getSource("addr").setData({
133
+ type: "FeatureCollection",
134
+ features: [{
135
+ type: "Feature",
136
+ geometry: { type: "Point", coordinates: [geo.lon, geo.lat] },
137
+ properties: { address: geo.address },
138
+ }],
139
+ });
140
+ // load all per-address layers in parallel
141
+ const url = (p) => `${p}?lat=${geo.lat}&lon=${geo.lon}&r=1500`;
142
+ const [sandy, dep, prithvi, fn] = await Promise.all([
143
+ fetch(url("/api/layers/sandy")).then(r => r.json()).catch(() => null),
144
+ fetch(url("/api/layers/dep_extreme_2080")).then(r => r.json()).catch(() => null),
145
+ fetch(url("/api/layers/prithvi_water")).then(r => r.json()).catch(() => null),
146
+ fetch(`/api/floodnet_near?lat=${geo.lat}&lon=${geo.lon}&r=1000`).then(r => r.json()).catch(() => null),
147
+ ]);
148
+ if (sandy) map.getSource("sandy").setData(sandy);
149
+ if (dep) map.getSource("dep").setData(dep);
150
+ if (prithvi) map.getSource("prithvi").setData(prithvi);
151
+ if (fn) map.getSource("floodnet").setData(fn);
152
+
153
+ // Hide the Prithvi legend item when no polygons render here. The
154
+ // model only marks satellite-observed water bodies — for landlocked
155
+ // addresses there's nothing to draw, and an empty legend entry would
156
+ // confuse rather than inform.
157
+ const prithviLegend = document.querySelector(".legend .sw.prithvi");
158
+ if (prithviLegend) {
159
+ const hasPrithvi = prithvi && (prithvi.features || []).length > 0;
160
+ prithviLegend.parentElement.style.display = hasPrithvi ? "" : "none";
161
+ }
162
+
163
+ map.flyTo({ center: [geo.lon, geo.lat], zoom: 14, speed: 1.2 });
164
+ }
165
+
166
+ function resetUI(query) {
167
+ $("#trace").classList.remove("hidden");
168
+ $("#report").classList.add("hidden");
169
+ $("#meta").classList.add("hidden");
170
+ $("#paragraph").innerHTML = "";
171
+ const kf = $("#keyFindings"); if (kf) kf.innerHTML = "";
172
+ const ec = $("#evidenceCards"); if (ec) ec.innerHTML = "";
173
+ const pl = $("#policyList"); if (pl) pl.innerHTML = "";
174
+ const ps = $("#policySection"); if (ps) ps.classList.add("hidden");
175
+ const s = $("#sources"); if (s) s.innerHTML = "";
176
+ $("#addr").innerHTML = "";
177
+ CITE_INDEX = {};
178
+
179
+ const ul = $("#steps");
180
+ ul.innerHTML = "";
181
+ for (const sid of STEPS_ORDER) {
182
+ const [lbl, hint] = STEP_LABELS[sid] || [sid, ""];
183
+ const li = document.createElement("li");
184
+ li.id = "step-" + sid;
185
+ li.className = "pending";
186
+ li.innerHTML = `
187
+ <span class="icon">○</span>
188
+ <div>
189
+ <div class="label">${lbl}</div>
190
+ <div class="meta">${hint}</div>
191
+ </div>
192
+ <span class="meta time"></span>`;
193
+ ul.appendChild(li);
194
+ }
195
+ // mark first one running
196
+ $("#step-" + STEPS_ORDER[0]).classList.replace("pending", "running");
197
+ }
198
+
199
+ function markStep(stepId, ev) {
200
+ const li = document.getElementById("step-" + stepId);
201
+ if (!li) return;
202
+ li.className = ev.ok ? "ok" : "err";
203
+ li.querySelector(".icon").textContent = ev.ok ? "✓" : "✗";
204
+ if (ev.elapsed_s != null) {
205
+ li.querySelector(".time").textContent = ev.elapsed_s.toFixed(2) + "s";
206
+ }
207
+ if (ev.result) {
208
+ let div = li.querySelector(".result");
209
+ if (!div) {
210
+ div = document.createElement("div");
211
+ div.className = "result";
212
+ li.appendChild(div);
213
+ }
214
+ div.textContent = formatResult(ev.result);
215
+ } else if (ev.err) {
216
+ let div = li.querySelector(".result");
217
+ if (!div) {
218
+ div = document.createElement("div");
219
+ div.className = "result";
220
+ li.appendChild(div);
221
+ }
222
+ div.textContent = "error: " + ev.err;
223
+ }
224
+
225
+ // mark next pending step running
226
+ const idx = STEPS_ORDER.indexOf(stepId);
227
+ if (idx >= 0 && idx + 1 < STEPS_ORDER.length) {
228
+ const next = document.getElementById("step-" + STEPS_ORDER[idx + 1]);
229
+ if (next && next.classList.contains("pending")) {
230
+ next.classList.replace("pending", "running");
231
+ }
232
+ }
233
+ }
234
+
235
+ function formatResult(r) {
236
+ if (typeof r !== "object") return String(r);
237
+ return Object.entries(r)
238
+ .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`)
239
+ .join(" · ");
240
+ }
241
+
242
+ // Map doc_id -> footnote number for the current report; built fresh each query
243
+ let CITE_INDEX = {};
244
+
245
+ function rewriteCitations(text) {
246
+ // Replace [doc_id] with <span class="cite" title="...">N</span> using the
247
+ // CITE_INDEX. doc_ids not in the index get their first appearance assigned.
248
+ return text.replace(/\[([a-z0-9_]+)\]/gi, (_, d) => {
249
+ const norm = d.toLowerCase();
250
+ if (CITE_INDEX[norm] == null) {
251
+ CITE_INDEX[norm] = Object.keys(CITE_INDEX).length + 1;
252
+ }
253
+ const n = CITE_INDEX[norm];
254
+ return `<span class="cite" title="source ${n} — ${SOURCE_LABELS[norm] || norm}">${n}</span>`;
255
+ });
256
+ }
257
+
258
+ function renderParagraph(text) {
259
+ $("#paragraph").innerHTML = rewriteCitations(text);
260
+ }
261
+
262
+ const SOURCE_LABELS = {
263
+ geocode: "NYC DCP Geosearch",
264
+ sandy: "NYC OpenData 5xsi-dfpx — Sandy 2012 inundation",
265
+ dep_extreme_2080: "NYC DEP Stormwater — Extreme 3.66 in/hr + 2080 SLR",
266
+ dep_moderate_2050: "NYC DEP Stormwater — Moderate 2.13 in/hr + 2050 SLR",
267
+ dep_moderate_current: "NYC DEP Stormwater — Moderate 2.13 in/hr current",
268
+ floodnet: "FloodNet NYC — live ultrasonic sensor network",
269
+ nyc311: "NYC 311 (Socrata erm2-nwe9) — flood descriptors",
270
+ microtopo: "USGS 3DEP 30 m DEM via py3dep",
271
+ ida_hwm: "USGS STN — Hurricane Ida 2021 HWMs (Event 312, NY)",
272
+ prithvi_water: "Prithvi-EO 2.0 (300M, NASA/IBM) — Hurricane Ida 2021 pre/post HLS diff (Aug 25 vs Sep 2)",
273
+ rag_dep_2013: "NYC DEP Wastewater Resiliency Plan (2013)",
274
+ rag_nycha: "NYCHA — Flood Resilience: Lessons Learned",
275
+ rag_coned: "Con Edison Climate Change Resilience Plan (Case 22-E-0222)",
276
+ rag_mta: "MTA Climate Resilience Roadmap (Oct 2025)",
277
+ rag_comptroller: "NYC Comptroller — \"Is NYC Ready for Rain?\" (2024)",
278
+ };
279
+
280
+ // ----------------------------------------------------------------------
281
+ // CIVIC ASSESSMENT REPORT — header strip, tier badge, key findings,
282
+ // evidence cards, policy quotes, methodology footer.
283
+ // ----------------------------------------------------------------------
284
+
285
+ function tierMeta(score) {
286
+ // Mirror app/score.py rubric: ≥6 = T1, 4-5 = T2, 2-3 = T3, 1 = T4, 0 = T0.
287
+ if (score >= 6) return {tier: 1, label: "High exposure", help: "Multiple positive flood signals — historical inundation and modeled scenarios both indicate substantial risk."};
288
+ if (score >= 4) return {tier: 2, label: "Elevated exposure", help: "Significant overlap with at least one empirical or modeled scenario."};
289
+ if (score >= 2) return {tier: 3, label: "Moderate exposure", help: "One or two positive signals; localised or scenario-specific risk."};
290
+ if (score >= 1) return {tier: 4, label: "Limited exposure", help: "A single contextual signal; no positive scenario hits."};
291
+ return {tier: 0, label: "No flagged exposure", help: "No positive flood signal across the assessed sources."};
292
+ }
293
+
294
+ function computeScore(ev) {
295
+ // Mirror server-side rubric so we render consistently.
296
+ let s = 0;
297
+ if (ev.sandy) s += 3;
298
+ const dep = ev.dep || {};
299
+ if ((dep.dep_extreme_2080?.depth_class || 0) > 0) s += 2;
300
+ if ((dep.dep_moderate_2050?.depth_class || 0) > 0) s += 2;
301
+ if ((dep.dep_moderate_current?.depth_class || 0) > 0) s += 1;
302
+ if ((ev.nyc311?.n || 0) >= 3) s += 1;
303
+ if ((ev.floodnet?.n_flood_events_3y || 0) > 0) s += 1;
304
+ if ((ev.rag || []).length) s += 1;
305
+ return s;
306
+ }
307
+
308
+ function renderHeader(ev) {
309
+ const geo = ev.geocode || {};
310
+ $("#reportAddr").textContent = geo.address || "(unresolved)";
311
+ $("#reportBoro").textContent = geo.borough || "—";
312
+ $("#reportBbl").textContent = geo.bbl || "—";
313
+ $("#reportTs").textContent = new Date().toISOString().slice(0,10);
314
+ }
315
+
316
+ function renderTier(ev) {
317
+ const score = computeScore(ev);
318
+ const m = tierMeta(score);
319
+ const badge = $("#tierBadge");
320
+ badge.className = "tier-badge t-" + m.tier;
321
+ $("#tierNum").textContent = m.tier;
322
+ $("#tierLabel").textContent = `Tier ${m.tier} — ${m.label}`;
323
+ $("#tierHelp").textContent = m.help;
324
+ }
325
+
326
+ function renderKeyFindings(ev) {
327
+ const dl = $("#keyFindings");
328
+ dl.innerHTML = "";
329
+ const rows = [];
330
+
331
+ rows.push(["Sandy 2012 zone",
332
+ ev.sandy ? "INSIDE" : "outside",
333
+ ev.sandy ? "hit" : "miss"]);
334
+
335
+ const dep = ev.dep || {};
336
+ const dHit = Object.entries(dep).find(([_, v]) => (v.depth_class || 0) > 0);
337
+ if (dHit) {
338
+ const [scen, v] = dHit;
339
+ const lbl = scen.replace("dep_", "").replace(/_/g, " ").toUpperCase();
340
+ rows.push(["DEP scenario", `${lbl} — ${v.depth_label}`, "hit"]);
341
+ } else {
342
+ rows.push(["DEP scenarios", "outside all 3", "miss"]);
343
+ }
344
+
345
+ const mt = ev.microtopo;
346
+ if (mt) {
347
+ rows.push(["Elevation",
348
+ `${mt.point_elev_m} m above sea level`, ""]);
349
+ if (mt.hand_m != null) {
350
+ rows.push(["Height Above Drainage", `${mt.hand_m} m (HAND)`, ""]);
351
+ }
352
+ if (mt.twi != null) {
353
+ rows.push(["Topographic Wetness Index",
354
+ `${mt.twi} (${mt.twi >= 14 ? "very high" : mt.twi >= 10 ? "high" : mt.twi >= 6 ? "moderate" : "low"})`, ""]);
355
+ }
356
+ }
357
+
358
+ const fn = ev.floodnet;
359
+ if (fn && fn.n_sensors > 0) {
360
+ rows.push(["FloodNet (3 yr)",
361
+ `${fn.n_flood_events_3y} events across ${fn.n_sensors} sensors`,
362
+ fn.n_flood_events_3y > 0 ? "hit" : ""]);
363
+ }
364
+
365
+ const ida = ev.ida_hwm;
366
+ if (ida && ida.n_within_radius > 0) {
367
+ const ht = ida.max_height_above_gnd_ft != null
368
+ ? `, max ${ida.max_height_above_gnd_ft} ft above ground` : "";
369
+ rows.push(["Hurricane Ida 2021 HWMs",
370
+ `${ida.n_within_radius} within ${ida.radius_m} m${ht}`, "hit"]);
371
+ }
372
+
373
+ const pw = ev.prithvi_water;
374
+ if (pw && pw.nearest_distance_m != null) {
375
+ rows.push(["Prithvi-EO Ida 2021",
376
+ pw.inside_water_polygon
377
+ ? "INSIDE inundation polygon"
378
+ : `${pw.nearest_distance_m} m to nearest inundation polygon`,
379
+ pw.inside_water_polygon ? "hit" : ""]);
380
+ }
381
+
382
+ const c311 = ev.nyc311;
383
+ if (c311 && c311.n > 0) {
384
+ rows.push(["311 flood complaints",
385
+ `${c311.n} within ${c311.radius_m} m, last ${c311.years} yr`,
386
+ c311.n >= 5 ? "hit" : ""]);
387
+ }
388
+
389
+ dl.innerHTML = rows.map(([k, v, cls]) =>
390
+ `<dt>${k}</dt><dd${cls ? ` class="${cls}"` : ""}>${v}</dd>`
391
+ ).join("");
392
+ }
393
+
394
+ function evCard({key, title, flag, rows, sourceText, sourceUrl, vintage, collapsed}) {
395
+ // flag: "hit" | "note" | "miss"
396
+ const inner = rows.map(([k, v]) =>
397
+ `<dt>${k}</dt><dd>${v}</dd>`).join("");
398
+ const foot = sourceUrl
399
+ ? `<a href="${sourceUrl}" target="_blank">${sourceText}</a>${vintage ? " · " + vintage : ""}`
400
+ : `${sourceText}${vintage ? " · " + vintage : ""}`;
401
+ const cls = "ec" + (collapsed ? " collapsed" : "");
402
+ return `<div class="${cls}" data-key="${key}">
403
+ <div class="ec-head" onclick="this.parentElement.classList.toggle('collapsed')">
404
+ <div class="ec-title"><span class="ec-flag ${flag}"></span>${title}</div>
405
+ <div class="ec-toggle">▾</div>
406
+ </div>
407
+ <div class="ec-body"><dl>${inner}</dl></div>
408
+ <div class="ec-foot">${foot}</div>
409
+ </div>`;
410
+ }
411
+
412
+ function renderEvidence(ev) {
413
+ const cards = [];
414
+
415
+ if (ev.sandy != null) {
416
+ cards.push(evCard({
417
+ key: "sandy", title: "Sandy 2012 inundation",
418
+ flag: ev.sandy ? "hit" : "miss",
419
+ rows: [
420
+ ["Inside extent", ev.sandy ? "yes" : "no"],
421
+ ["Reference event", "Hurricane Sandy, 29-30 Oct 2012"],
422
+ ],
423
+ sourceText: "NYC OpenData 5xsi-dfpx",
424
+ sourceUrl: "https://data.cityofnewyork.us/Environment/Sandy-Inundation-Zone/uyj8-7rv5",
425
+ vintage: "empirical 2012 extent",
426
+ collapsed: !ev.sandy,
427
+ }));
428
+ }
429
+
430
+ const dep = ev.dep || {};
431
+ const depRows = [];
432
+ for (const [k, v] of Object.entries(dep)) {
433
+ const label = k.replace("dep_", "").replace(/_/g, " ");
434
+ depRows.push([label,
435
+ v.depth_class > 0 ? `${v.depth_label}` : "outside"]);
436
+ }
437
+ if (depRows.length) {
438
+ const anyHit = Object.values(dep).some(v => (v.depth_class || 0) > 0);
439
+ cards.push(evCard({
440
+ key: "dep", title: "DEP Stormwater scenarios",
441
+ flag: anyHit ? "hit" : "miss",
442
+ rows: depRows,
443
+ sourceText: "NYC DEP via NYC OpenData 9i7c-xyvv",
444
+ sourceUrl: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Maps/9i7c-xyvv",
445
+ vintage: "modeled, 2021 release",
446
+ collapsed: !anyHit,
447
+ }));
448
+ }
449
+
450
+ const fn = ev.floodnet;
451
+ if (fn && fn.n_sensors > 0) {
452
+ const peak = fn.peak_event;
453
+ const rows = [
454
+ ["Sensors within 600 m", String(fn.n_sensors)],
455
+ ["Flood events, last 3 yr", String(fn.n_flood_events_3y)],
456
+ ];
457
+ if (peak && peak.max_depth_mm) {
458
+ rows.push(["Peak event", `${peak.max_depth_mm} mm depth at ${peak.deployment_id}`]);
459
+ rows.push(["Peak date", (peak.start_time || "").slice(0, 10)]);
460
+ }
461
+ cards.push(evCard({
462
+ key: "floodnet", title: "FloodNet sensor network",
463
+ flag: fn.n_flood_events_3y > 0 ? "hit" : "note",
464
+ rows,
465
+ sourceText: "FloodNet NYC (NYU/CUNY/MOCEJ)",
466
+ sourceUrl: "https://www.floodnet.nyc/",
467
+ vintage: "live, queried per request",
468
+ collapsed: false,
469
+ }));
470
+ }
471
+
472
+ const ida = ev.ida_hwm;
473
+ if (ida && ida.n_within_radius > 0) {
474
+ const rows = [
475
+ ["HWMs within 800 m", String(ida.n_within_radius)],
476
+ ];
477
+ if (ida.max_height_above_gnd_ft != null)
478
+ rows.push(["Max above-ground height", `${ida.max_height_above_gnd_ft} ft`]);
479
+ if (ida.max_elev_ft != null)
480
+ rows.push(["Max HWM elevation", `${ida.max_elev_ft} ft`]);
481
+ if (ida.nearest_dist_m != null)
482
+ rows.push(["Nearest HWM site", `${ida.nearest_site || "—"} (${ida.nearest_dist_m} m)`]);
483
+ cards.push(evCard({
484
+ key: "ida_hwm", title: "Hurricane Ida 2021 high-water marks",
485
+ flag: "hit", rows,
486
+ sourceText: "USGS Short-Term Network, Event 312 (NY)",
487
+ sourceUrl: "https://stn.wim.usgs.gov/",
488
+ vintage: "post-event survey, Sep 2021",
489
+ collapsed: false,
490
+ }));
491
+ }
492
+
493
+ const mt = ev.microtopo;
494
+ if (mt) {
495
+ const rows = [
496
+ ["Elevation", `${mt.point_elev_m} m`],
497
+ ["Lower than (200 m)", `${mt.rel_elev_pct_200m}% of cells`],
498
+ ["Lower than (750 m)", `${mt.rel_elev_pct_750m}% of cells`],
499
+ ["Basin relief (750 m)", `${mt.basin_relief_m} m`],
500
+ ];
501
+ if (mt.hand_m != null) rows.push(["HAND", `${mt.hand_m} m`]);
502
+ if (mt.twi != null) rows.push(["TWI", String(mt.twi)]);
503
+ cards.push(evCard({
504
+ key: "microtopo", title: "LiDAR-derived terrain (DEM + TWI + HAND)",
505
+ flag: "note", rows,
506
+ sourceText: "USGS 3DEP DEM via py3dep · whitebox-workflows hydrology",
507
+ sourceUrl: "https://www.usgs.gov/3d-elevation-program",
508
+ vintage: "DEM 30 m, hydro-conditioned",
509
+ collapsed: false,
510
+ }));
511
+ }
512
+
513
+ const pw = ev.prithvi_water;
514
+ if (pw && pw.nearest_distance_m != null) {
515
+ const rows = [
516
+ ["Inside Ida-attributable polygon", pw.inside_water_polygon ? "yes" : "no"],
517
+ ["Nearest inundation polygon", `${pw.nearest_distance_m} m`],
518
+ ["Inundation polygons within 500 m", String(pw.n_polygons_within_500m)],
519
+ ["Pre-event scene", "HLS T18TWK 2021-08-25 (3% cloud)"],
520
+ ["Post-event scene", "HLS T18TWK 2021-09-02 (1% cloud, ~12 h after Ida peak)"],
521
+ ];
522
+ cards.push(evCard({
523
+ key: "prithvi_water",
524
+ title: "Prithvi-EO 2.0 — Hurricane Ida flood inundation",
525
+ flag: pw.inside_water_polygon ? "hit" : "note", rows,
526
+ sourceText: "NASA / IBM Prithvi-EO-2.0-300M-TL-Sen1Floods11 (Apache-2.0, 300M params, run via TerraTorch on HLS Sentinel-2)",
527
+ sourceUrl: "https://huggingface.co/ibm-nasa-geospatial/Prithvi-EO-2.0-300M-TL-Sen1Floods11",
528
+ vintage: "Polygons = post-event water minus pre-event water. Sub-surface flooding (subway / basement) not visible to optical satellites.",
529
+ collapsed: false,
530
+ }));
531
+ }
532
+
533
+ const c311 = ev.nyc311;
534
+ if (c311 && c311.n > 0) {
535
+ const rows = [
536
+ ["Total complaints", String(c311.n)],
537
+ ["Buffer", `${c311.radius_m} m`],
538
+ ["Window", `${c311.years} years`],
539
+ ];
540
+ if (c311.by_descriptor) {
541
+ const top = Object.entries(c311.by_descriptor).slice(0, 3)
542
+ .map(([k, v]) => `${v}× ${k.replace(/\s*\(.+?\)\s*$/, "").replace(/\s*\(SA\d?\)?$/, "")}`)
543
+ .join("; ");
544
+ if (top) rows.push(["Top descriptors", top]);
545
+ }
546
+ if (c311.by_year) {
547
+ const yrs = Object.entries(c311.by_year).map(([y, n]) => `${y}: ${n}`).join(", ");
548
+ rows.push(["By year", yrs]);
549
+ }
550
+ cards.push(evCard({
551
+ key: "nyc311", title: "NYC 311 flood complaints",
552
+ flag: c311.n >= 5 ? "hit" : "note", rows,
553
+ sourceText: "NYC 311 (Socrata erm2-nwe9)",
554
+ sourceUrl: "https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9",
555
+ vintage: "live, last 5 years",
556
+ collapsed: false,
557
+ }));
558
+ }
559
+
560
+ $("#evidenceCards").innerHTML = cards.join("");
561
+ }
562
+
563
+ function renderPolicy(ev) {
564
+ const policy = $("#policySection");
565
+ const rag = ev.rag || [];
566
+ if (!rag.length) { policy.classList.add("hidden"); return; }
567
+ policy.classList.remove("hidden");
568
+ const items = rag.map(h => `<li>
569
+ <div class="policy-title">${h.title || h.doc_id}</div>
570
+ <div class="policy-quote">${(h.text || "").replace(/^"|"$/g, "").trim()}</div>
571
+ <div class="policy-cite">${h.citation || ""}${h.page ? " · p. " + h.page : ""}</div>
572
+ </li>`);
573
+ $("#policyList").innerHTML = items.join("");
574
+ }
575
+
576
+ function renderEnergy(ev) {
577
+ const en = ev.energy;
578
+ if (!en) return;
579
+ $("#energyLocal").textContent = `${en.local_mwh} mWh`;
580
+ $("#energyCloud").textContent = `~${en.cloud_mwh} mWh`;
581
+ $("#energyRatio").textContent = en.ratio_cloud_over_local
582
+ ? `${en.ratio_cloud_over_local}×`
583
+ : "—";
584
+ }
585
+
586
+ function renderEnergy(ev) {
587
+ const en = ev.energy;
588
+ if (!en) return;
589
+ const $$ = (id) => document.getElementById(id);
590
+ $$("energyLocal").textContent = `${en.local_mwh} mWh`;
591
+ $$("energyCloud").textContent = `~${en.cloud_mwh} mWh`;
592
+ $$("energyRatio").textContent = en.ratio_cloud_over_local
593
+ ? `${en.ratio_cloud_over_local}×`
594
+ : "—";
595
+ const m = en.method || {};
596
+ $$("energyMethod").innerHTML =
597
+ `Local: ${m.local} (q4_K_M, package power; ${m.local_source}). ` +
598
+ `Cloud: ${m.cloud} (${m.cloud_source}).`;
599
+ }
600
+
601
+ function renderNumberedSources() {
602
+ // Render the methodology footer's <ol> in CITE_INDEX order so the [n]
603
+ // superscripts in the lede paragraph match. CITE_INDEX is populated
604
+ // by rewriteCitations() during renderParagraph().
605
+ const ol = $("#sources");
606
+ if (!ol) return;
607
+ const entries = Object.entries(CITE_INDEX).sort((a, b) => a[1] - b[1]);
608
+ ol.innerHTML = entries.map(([doc_id, n]) =>
609
+ `<li value="${n}">${SOURCE_LABELS[doc_id] || doc_id} <code>[${doc_id}]</code></li>`
610
+ ).join("");
611
+ }
612
+
613
+ function renderAddress(g) {
614
+ const dl = $("#addr");
615
+ dl.innerHTML = "";
616
+ const rows = [
617
+ ["address", g.address],
618
+ ["borough", g.borough || ""],
619
+ ["lat / lon", `${g.lat.toFixed(5)}, ${g.lon.toFixed(5)}`],
620
+ ["BBL", g.bbl || ""],
621
+ ["BIN", g.bin || ""],
622
+ ];
623
+ for (const [k, v] of rows) {
624
+ if (!v) continue;
625
+ const dt = document.createElement("dt"); dt.textContent = k;
626
+ const dd = document.createElement("dd"); dd.textContent = v;
627
+ dl.appendChild(dt); dl.appendChild(dd);
628
+ }
629
+ }
630
+
631
+ // Suggested-address chips fill the input and submit
632
+ document.querySelectorAll(".chip[data-q]").forEach((btn) => {
633
+ btn.addEventListener("click", (e) => {
634
+ e.preventDefault();
635
+ $("#q").value = btn.getAttribute("data-q");
636
+ $("#qform").requestSubmit();
637
+ });
638
+ });
639
+
640
+ $("#qform").addEventListener("submit", (e) => {
641
+ e.preventDefault();
642
+ const q = $("#q").value.trim();
643
+ if (!q) return;
644
+ if (evtSrc) evtSrc.close();
645
+ resetUI(q);
646
+ $("#go").disabled = true;
647
+ evtSrc = new EventSource("/api/stream?q=" + encodeURIComponent(q));
648
+
649
+ evtSrc.addEventListener("step", (msg) => {
650
+ const ev = JSON.parse(msg.data);
651
+ markStep(ev.step, ev);
652
+ });
653
+ evtSrc.addEventListener("final", (msg) => {
654
+ const ev = JSON.parse(msg.data);
655
+ $("#report").classList.remove("hidden");
656
+ $("#meta").classList.remove("hidden");
657
+ $("#map-card").classList.remove("hidden");
658
+ // Reset citation index for this query before any citation rewriting
659
+ CITE_INDEX = {};
660
+ if (ev.geocode) {
661
+ renderAddress(ev.geocode);
662
+ updateMapForResult(ev.geocode);
663
+ }
664
+ renderHeader(ev);
665
+ renderTier(ev);
666
+ if (ev.paragraph) renderParagraph(ev.paragraph);
667
+ renderKeyFindings(ev);
668
+ renderEvidence(ev);
669
+ renderPolicy(ev);
670
+ renderEnergy(ev);
671
+ renderNumberedSources();
672
+ });
673
+ evtSrc.addEventListener("done", () => {
674
+ $("#go").disabled = false;
675
+ evtSrc.close();
676
+ });
677
+ evtSrc.addEventListener("error", (msg) => {
678
+ console.error("SSE error", msg);
679
+ $("#go").disabled = false;
680
+ evtSrc.close();
681
+ });
682
+ });
web/static/index.html ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 — flood risk, NYC</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>
12
+ <header class="topbar">
13
+ <div class="topbar-inner">
14
+ <div class="brand">
15
+ <span class="brand-name">Riprap</span>
16
+ <span class="brand-sep">·</span>
17
+ <span class="brand-tag">citation-grounded civic AI for NYC flood risk</span>
18
+ </div>
19
+ <div class="topbar-right">
20
+ <a href="/compare" class="modelink">compare</a>
21
+ <a href="/register/schools" class="modelink">register</a>
22
+ <span class="local-pill" title="Granite 4.1 inference runs on this machine. No vendor LLM is contacted.">
23
+ <span class="dot"></span>local · Granite 4.1 / Ollama
24
+ </span>
25
+ </div>
26
+ </div>
27
+ </header>
28
+
29
+ <div class="form-bar">
30
+ <form id="qform">
31
+ <input type="text" id="q" name="q" autocomplete="off"
32
+ placeholder='NYC address — e.g. "180 Beach 35 St, Queens"'
33
+ value="180 Beach 35 St, Queens"
34
+ required />
35
+ <button type="submit" id="go">Analyze</button>
36
+ </form>
37
+ <div class="suggest">
38
+ <span class="suggest-label">try:</span>
39
+ <button class="chip" data-q="180 Beach 35 St, Queens">Far Rockaway · Sandy zone</button>
40
+ <button class="chip" data-q="280 Broome St, Manhattan">LES · Lower Manhattan</button>
41
+ <button class="chip" data-q="153-09 90 Avenue, Jamaica, Queens">Jamaica · Ida basements</button>
42
+ <button class="chip" data-q="2950 W 25 St, Brooklyn">Coney Island · NYCHA</button>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="workbench">
47
+
48
+ <!-- LEFT: trace + address details -->
49
+ <aside class="col-left">
50
+ <section id="trace" class="panel">
51
+ <h2>Specialist trace<span class="hint">Burr FSM · 8 nodes</span></h2>
52
+ <ul id="steps"></ul>
53
+ </section>
54
+
55
+ <section id="meta" class="panel hidden">
56
+ <h2>Address resolved</h2>
57
+ <dl id="addr"></dl>
58
+ </section>
59
+ </aside>
60
+
61
+ <!-- MIDDLE: map -->
62
+ <section class="col-mid">
63
+ <div id="map-card" class="panel panel-map hidden">
64
+ <h2>Map<span class="hint">Sandy 2012 · DEP extreme 2080 · Prithvi-EO Ida inundation · FloodNet</span></h2>
65
+ <div id="map"></div>
66
+ <div class="legend">
67
+ <span><i class="sw sandy"></i>Sandy 2012 zone</span>
68
+ <span><i class="sw dep"></i>DEP Extreme 2080</span>
69
+ <span><i class="sw prithvi"></i>Prithvi-EO Ida 2021 inundation</span>
70
+ <span><i class="sw fnDot"></i>FloodNet (no events)</span>
71
+ <span><i class="sw fnDotHot"></i>FloodNet w/ events</span>
72
+ <span><i class="sw addr"></i>Queried address</span>
73
+ </div>
74
+ </div>
75
+ </section>
76
+
77
+ <!-- RIGHT: structured cited assessment -->
78
+ <aside class="col-right">
79
+ <section id="report" class="report panel hidden">
80
+
81
+ <header class="report-head">
82
+ <div class="report-id">
83
+ <div class="report-eyebrow">Flood exposure assessment</div>
84
+ <div class="report-addr" id="reportAddr">—</div>
85
+ <div class="report-meta">
86
+ <span id="reportBoro">—</span>
87
+ <span class="sep">·</span>
88
+ <span class="report-meta-k">BBL</span>
89
+ <span class="report-meta-v" id="reportBbl">—</span>
90
+ <span class="sep">·</span>
91
+ <span class="report-meta-k">Assessed</span>
92
+ <span class="report-meta-v" id="reportTs">—</span>
93
+ </div>
94
+ </div>
95
+ </header>
96
+
97
+ <div class="tier-block">
98
+ <div class="tier-badge tier-pending" id="tierBadge">
99
+ <div class="tier-badge-num" id="tierNum">—</div>
100
+ <div class="tier-badge-of">of 4</div>
101
+ </div>
102
+ <div class="tier-text">
103
+ <div class="tier-label" id="tierLabel">Awaiting assessment</div>
104
+ <div class="tier-help" id="tierHelp">—</div>
105
+ </div>
106
+ </div>
107
+
108
+ <div class="summary-box">
109
+ <div class="report-section-h">Summary</div>
110
+ <p id="paragraph"></p>
111
+ </div>
112
+
113
+ <div class="key-findings">
114
+ <div class="report-section-h">Key findings</div>
115
+ <dl id="keyFindings"></dl>
116
+ </div>
117
+
118
+ <div class="evidence-stack">
119
+ <div class="report-section-h">Evidence by source</div>
120
+ <div id="evidenceCards"></div>
121
+ </div>
122
+
123
+ <div class="policy-stack hidden" id="policySection">
124
+ <div class="report-section-h">Policy context <span class="hint-inline">(retrieved verbatim from agency reports)</span></div>
125
+ <ol id="policyList"></ol>
126
+ </div>
127
+
128
+ <div class="energy-footer">
129
+ <span class="energy-num-inline" id="energyLocal">—</span> local
130
+ <span class="sep">·</span>
131
+ <span class="energy-num-inline" id="energyCloud">—</span> est. frontier cloud
132
+ <span class="sep">·</span>
133
+ <span class="energy-num-inline" id="energyRatio">—</span> ratio
134
+ <span class="sep">·</span>
135
+ <span class="report-meta-k">via</span> Granite 4.1, local Ollama
136
+ </div>
137
+
138
+ <details class="methodology">
139
+ <summary>Methodology &amp; sources</summary>
140
+ <div class="method-body">
141
+ <p>
142
+ <strong>Specialists.</strong> Riprap runs nine independent specialists per query: NYC DCP Geosearch, Sandy Inundation Zone (NYC OD), three DEP Stormwater scenarios, FloodNet sensor network, NYC 311, USGS 3DEP LiDAR-derived terrain (elevation percentile + TWI + HAND), USGS STN Hurricane Ida 2021 high-water marks, Prithvi-EO 2.0 satellite water segmentation, and Granite Embedding RAG over NYC resilience plans. A specialist that produces no grounded data emits no document.
143
+ </p>
144
+ <p>
145
+ <strong>Tier rubric.</strong> Sandy 2012 zone: +3. DEP Extreme 2080 footprint: +2. DEP Moderate 2050 footprint: +2. DEP Moderate current: +1. Three or more 311 flood complaints in 200&nbsp;m, last 5&nbsp;yr: +1. FloodNet sensor with ≥1 event in 400&nbsp;m: +1. RAG hit naming this asset class: +1. Tier 1 if score ≥ 6, Tier 2 if 4-5, Tier 3 if 2-3, Tier 4 if 1, Tier 0 if 0.
146
+ </p>
147
+ <p>
148
+ <strong>Hallucination guardrail.</strong> Every numerical token in the Granite-generated paragraph is verified to appear in the source documents; sentences with ungrounded numbers are dropped. The Granite output is grounded via the model's native <code>document</code>-role chat template (Granite 4.1 / Ollama).
149
+ </p>
150
+ <h4>Numbered sources for this assessment</h4>
151
+ <ol id="sources"></ol>
152
+ </div>
153
+ </details>
154
+ </section>
155
+ </aside>
156
+
157
+ </div>
158
+
159
+ <footer>
160
+ <div class="foot-inner">
161
+ <div class="foot-col">
162
+ <h3>Why local Granite</h3>
163
+ <p>
164
+ Inference runs on the machine Riprap is deployed on, not in a
165
+ vendor LLM. That keeps queries and source documents inside an
166
+ organization's data boundary — material for newsrooms,
167
+ agencies, and researchers under FOIL or IRB constraints. The
168
+ reconciler uses an open-weight model in the ~3-billion-parameter
169
+ range; on Apple Silicon a single grounded query draws roughly
170
+ 0.03&nbsp;Wh, an estimated order of magnitude below the
171
+ ~0.3&nbsp;Wh-per-query figure
172
+ <a href="https://epoch.ai/gradient-updates/how-much-energy-does-chatgpt-use" target="_blank">Epoch&nbsp;AI</a>
173
+ publishes for typical frontier-cloud (GPT-4o-class) queries.
174
+ Public NYC and USGS data services receive the resolved
175
+ coordinates for sensor and complaint lookups.
176
+ </p>
177
+ </div>
178
+ <div class="foot-col">
179
+ <h3>Sources</h3>
180
+ <p>
181
+ <a href="https://data.cityofnewyork.us/" target="_blank">NYC Open Data</a> ·
182
+ <a href="https://api.floodnet.nyc/" target="_blank">FloodNet NYC</a> ·
183
+ <a href="https://geosearch.planninglabs.nyc/" target="_blank">NYC DCP Geosearch</a> ·
184
+ <a href="https://stn.wim.usgs.gov/" target="_blank">USGS STN</a> ·
185
+ <a href="https://www.usgs.gov/3d-elevation-program" target="_blank">USGS 3DEP</a>
186
+ </p>
187
+ <h3 style="margin-top: 14px;">Models</h3>
188
+ <p>
189
+ <a href="https://huggingface.co/ibm-granite" target="_blank">IBM Granite 4.1</a> &
190
+ <a href="https://huggingface.co/ibm-granite/granite-embedding-278m-multilingual" target="_blank">Granite Embedding 278M</a>
191
+ via <a href="https://ollama.com" target="_blank">Ollama</a> +
192
+ sentence-transformers
193
+ </p>
194
+ </div>
195
+ </div>
196
+ </footer>
197
+
198
+ <script src="/static/app.js"></script>
199
+ </body>
200
+ </html>
web/static/style.css ADDED
@@ -0,0 +1,1098 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===============================================================
2
+ Riprap — visual idiom adapted from NYC Planning Labs
3
+ (ZoLa, Capital Planning Explorer, Population FactFinder).
4
+ Goal: dense, professional analytical tool. Map-dominant.
5
+ No marketing chrome.
6
+ =============================================================== */
7
+
8
+ @font-face {
9
+ font-family: "IBM Plex Sans";
10
+ src: url("/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Regular.woff2") format("woff2");
11
+ font-weight: 400; font-style: normal; font-display: swap;
12
+ }
13
+ @font-face {
14
+ font-family: "IBM Plex Sans";
15
+ src: url("/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Italic.woff2") format("woff2");
16
+ font-weight: 400; font-style: italic; font-display: swap;
17
+ }
18
+ @font-face {
19
+ font-family: "IBM Plex Sans";
20
+ src: url("/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Bold.woff2") format("woff2");
21
+ font-weight: 700; font-style: normal; font-display: swap;
22
+ }
23
+
24
+ :root {
25
+ --nyc-blue: #1642DF;
26
+ --nyc-blue-dark: #031553;
27
+ --nyc-blue-soft: #e8efff;
28
+ --nyc-scarlet: #e63946;
29
+
30
+ --bg: #ffffff;
31
+ --bg-soft: #f6f8fb;
32
+ --panel: #ffffff;
33
+ --line: #dde2ea;
34
+ --line-strong: #c5ccd6;
35
+
36
+ --text: #14161a;
37
+ --text-muted: #5b6470;
38
+ --text-faint: #8b95a3;
39
+
40
+ --good: #1a8754;
41
+ --warn: #b97700;
42
+
43
+ --sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
44
+ --mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
45
+
46
+ --topbar-h: 44px;
47
+ --formbar-h: auto;
48
+ }
49
+
50
+ * { box-sizing: border-box; }
51
+
52
+ html, body {
53
+ margin: 0; padding: 0;
54
+ font: 14px/1.45 var(--sans);
55
+ color: var(--text);
56
+ background: var(--bg-soft);
57
+ min-height: 100vh;
58
+ }
59
+
60
+ a { color: var(--nyc-blue); text-decoration: none; }
61
+ a:hover { text-decoration: underline; }
62
+
63
+ /* ----- Topbar ----- */
64
+
65
+ .topbar {
66
+ height: var(--topbar-h);
67
+ background: #fff;
68
+ border-bottom: 1px solid var(--line);
69
+ position: sticky; top: 0; z-index: 20;
70
+ }
71
+
72
+ .topbar-inner {
73
+ max-width: 1480px;
74
+ margin: 0 auto;
75
+ height: 100%;
76
+ padding: 0 20px;
77
+ display: flex; align-items: center; justify-content: space-between;
78
+ gap: 16px;
79
+ }
80
+
81
+ .brand {
82
+ display: inline-flex; align-items: baseline; gap: 8px;
83
+ font-size: 13.5px;
84
+ }
85
+
86
+ .brand-name {
87
+ font-weight: 700;
88
+ font-size: 16px;
89
+ color: var(--nyc-blue);
90
+ letter-spacing: -0.005em;
91
+ }
92
+
93
+ .brand-sep { color: var(--text-faint); }
94
+ .brand-tag { color: var(--text-muted); }
95
+
96
+ .topbar-right { display: flex; align-items: center; gap: 12px; }
97
+
98
+ .local-pill {
99
+ display: inline-flex; align-items: center; gap: 8px;
100
+ padding: 4px 10px;
101
+ font-size: 11.5px;
102
+ font-family: var(--mono);
103
+ color: var(--good);
104
+ background: rgba(26, 135, 84, 0.08);
105
+ border: 1px solid rgba(26, 135, 84, 0.30);
106
+ border-radius: 999px;
107
+ cursor: help;
108
+ }
109
+
110
+ .local-pill .dot {
111
+ width: 7px; height: 7px; border-radius: 50%;
112
+ background: var(--good);
113
+ box-shadow: 0 0 6px rgba(26, 135, 84, 0.7);
114
+ animation: pulse 2s infinite;
115
+ }
116
+
117
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
118
+
119
+ /* ----- Form bar ----- */
120
+
121
+ .form-bar {
122
+ background: #fff;
123
+ border-bottom: 1px solid var(--line);
124
+ padding: 14px 20px 12px;
125
+ }
126
+
127
+ #qform {
128
+ max-width: 1480px;
129
+ margin: 0 auto;
130
+ display: flex; gap: 8px;
131
+ }
132
+
133
+ input[type="text"] {
134
+ flex: 1;
135
+ padding: 9px 12px;
136
+ font: inherit;
137
+ font-size: 14px;
138
+ color: var(--text);
139
+ background: #fff;
140
+ border: 1px solid var(--line-strong);
141
+ border-radius: 3px;
142
+ outline: none;
143
+ transition: border-color 0.12s, box-shadow 0.12s;
144
+ }
145
+
146
+ input[type="text"]:focus {
147
+ border-color: var(--nyc-blue);
148
+ box-shadow: 0 0 0 2px rgba(22, 66, 223, 0.16);
149
+ }
150
+
151
+ button {
152
+ padding: 9px 18px;
153
+ font: inherit;
154
+ font-size: 14px;
155
+ font-weight: 500;
156
+ color: #fff;
157
+ background: var(--nyc-blue);
158
+ border: 1px solid var(--nyc-blue);
159
+ border-radius: 3px;
160
+ cursor: pointer;
161
+ transition: background 0.12s;
162
+ }
163
+
164
+ button:hover:not(:disabled) { background: var(--nyc-blue-dark); border-color: var(--nyc-blue-dark); }
165
+ button:disabled { opacity: 0.55; cursor: not-allowed; }
166
+
167
+ .suggest {
168
+ max-width: 1480px;
169
+ margin: 8px auto 0;
170
+ display: flex; flex-wrap: wrap; gap: 6px;
171
+ align-items: center;
172
+ }
173
+
174
+ .suggest-label {
175
+ font-size: 11.5px;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.06em;
178
+ color: var(--text-faint);
179
+ margin-right: 4px;
180
+ }
181
+
182
+ button.chip {
183
+ padding: 3px 9px;
184
+ font-size: 12px;
185
+ font-weight: 400;
186
+ color: var(--text-muted);
187
+ background: #fff;
188
+ border: 1px solid var(--line);
189
+ border-radius: 999px;
190
+ cursor: pointer;
191
+ }
192
+
193
+ button.chip:hover {
194
+ background: var(--nyc-blue-soft);
195
+ border-color: var(--nyc-blue);
196
+ color: var(--nyc-blue-dark);
197
+ }
198
+
199
+ /* ----- Workbench (3-column: trace | map | report) ----- */
200
+
201
+ .workbench {
202
+ max-width: 1640px;
203
+ margin: 0 auto;
204
+ padding: 14px 20px;
205
+ display: grid;
206
+ grid-template-columns: 320px minmax(0, 1fr) 400px;
207
+ gap: 14px;
208
+ align-items: start;
209
+ }
210
+
211
+ .col-left {
212
+ display: flex; flex-direction: column; gap: 12px;
213
+ position: sticky; top: calc(var(--topbar-h) + 14px);
214
+ }
215
+ .col-mid {
216
+ display: flex; flex-direction: column; gap: 12px;
217
+ min-width: 0;
218
+ }
219
+ .col-right {
220
+ display: flex; flex-direction: column; gap: 12px;
221
+ position: sticky; top: calc(var(--topbar-h) + 14px);
222
+ max-height: calc(100vh - var(--topbar-h) - 28px);
223
+ overflow: auto;
224
+ }
225
+
226
+ /* ----- Panels ----- */
227
+
228
+ .panel {
229
+ background: var(--panel);
230
+ border: 1px solid var(--line);
231
+ border-radius: 4px;
232
+ overflow: hidden;
233
+ }
234
+
235
+ .panel > h2,
236
+ .panel-head h2 {
237
+ margin: 0;
238
+ padding: 9px 14px;
239
+ font-size: 11px;
240
+ font-weight: 700;
241
+ text-transform: uppercase;
242
+ letter-spacing: 0.10em;
243
+ color: var(--text-muted);
244
+ background: var(--bg-soft);
245
+ border-bottom: 1px solid var(--line);
246
+ display: flex; align-items: baseline; gap: 10px;
247
+ }
248
+
249
+ .panel > h2 .hint,
250
+ .panel-head h2 .hint {
251
+ font-weight: 400;
252
+ text-transform: none;
253
+ letter-spacing: 0;
254
+ color: var(--text-faint);
255
+ font-size: 10.5px;
256
+ margin-left: auto;
257
+ }
258
+
259
+ /* let panel sections style their own bodies */
260
+ #trace, #meta, #report { padding: 0; }
261
+
262
+ .panel-head {
263
+ border-bottom: 1px solid var(--line);
264
+ background: var(--bg-soft);
265
+ }
266
+
267
+ .hidden { display: none; }
268
+
269
+ /* ----- Specialist trace ----- */
270
+
271
+ #steps {
272
+ list-style: none;
273
+ margin: 0;
274
+ padding: 4px 0;
275
+ font-size: 12.5px;
276
+ }
277
+
278
+ #steps li {
279
+ display: grid;
280
+ grid-template-columns: 18px 1fr auto;
281
+ gap: 10px;
282
+ padding: 7px 14px;
283
+ border-bottom: 1px solid var(--line);
284
+ align-items: baseline;
285
+ }
286
+
287
+ #steps li:last-child { border-bottom: 0; }
288
+
289
+ #steps .icon { font-weight: 700; font-size: 14px; line-height: 1; }
290
+ #steps .pending .icon { color: var(--text-faint); }
291
+ #steps .running .icon { color: var(--nyc-blue); }
292
+ #steps .ok .icon { color: var(--good); }
293
+ #steps .err .icon { color: var(--nyc-scarlet); }
294
+
295
+ #steps .label { color: var(--text); font-weight: 500; }
296
+ #steps .meta { color: var(--text-muted); font-size: 11px; }
297
+ #steps .time { font-family: var(--mono); color: var(--text-faint); font-size: 11.5px; }
298
+
299
+ #steps .running { background: rgba(22, 66, 223, 0.04); }
300
+
301
+ #steps .result {
302
+ grid-column: 2 / -1;
303
+ color: var(--text-muted);
304
+ font-size: 11px;
305
+ font-family: var(--mono);
306
+ margin-top: 3px;
307
+ word-break: break-word;
308
+ line-height: 1.4;
309
+ }
310
+
311
+ /* ----- Address resolved ----- */
312
+
313
+ #addr {
314
+ margin: 0;
315
+ padding: 10px 14px;
316
+ display: grid;
317
+ grid-template-columns: max-content 1fr;
318
+ column-gap: 14px;
319
+ row-gap: 4px;
320
+ font-size: 12.5px;
321
+ }
322
+
323
+ #addr dt {
324
+ color: var(--text-muted);
325
+ text-transform: uppercase;
326
+ letter-spacing: 0.04em;
327
+ font-size: 10.5px;
328
+ }
329
+
330
+ #addr dd {
331
+ margin: 0;
332
+ font-family: var(--mono);
333
+ color: var(--text);
334
+ font-size: 12px;
335
+ word-break: break-word;
336
+ }
337
+
338
+ /* ----- Map ----- */
339
+
340
+ .panel-map { padding: 0; }
341
+ #map {
342
+ width: 100%;
343
+ height: 60vh;
344
+ min-height: 440px;
345
+ background: var(--bg-soft);
346
+ }
347
+
348
+ .legend {
349
+ display: flex; flex-wrap: wrap; gap: 14px;
350
+ padding: 8px 14px;
351
+ font-size: 11px;
352
+ color: var(--text-muted);
353
+ font-family: var(--mono);
354
+ border-top: 1px solid var(--line);
355
+ align-items: center;
356
+ }
357
+
358
+ .legend i.sw {
359
+ display: inline-block;
360
+ width: 11px; height: 11px;
361
+ border-radius: 2px;
362
+ margin-right: 5px;
363
+ vertical-align: middle;
364
+ border: 1px solid rgba(0,0,0,0.12);
365
+ }
366
+ .legend i.sandy { background: rgba(230, 57, 70, 0.45); }
367
+ .legend i.dep { background: rgba(22, 66, 223, 0.30); }
368
+ .legend i.prithvi { background: rgba(13, 148, 136, 0.20); border-color: #0d9488; }
369
+ .legend i.fnDot { border-radius: 50%; background: var(--good); }
370
+ .legend i.fnDotHot { border-radius: 50%; background: var(--nyc-scarlet); }
371
+ .legend i.addr { border-radius: 50%; background: var(--nyc-blue); border-color: #fff; }
372
+
373
+ /* ----- Civic-assessment report panel ----- */
374
+
375
+ .report { padding: 0; }
376
+
377
+ .report-head {
378
+ padding: 14px 16px 12px;
379
+ border-bottom: 1px solid var(--line);
380
+ background: linear-gradient(180deg, var(--bg-soft) 0%, #fff 100%);
381
+ }
382
+
383
+ .report-eyebrow {
384
+ font-size: 10px;
385
+ font-weight: 700;
386
+ letter-spacing: 0.10em;
387
+ text-transform: uppercase;
388
+ color: var(--nyc-blue);
389
+ }
390
+
391
+ .report-addr {
392
+ margin-top: 4px;
393
+ font-size: 16px;
394
+ font-weight: 600;
395
+ line-height: 1.25;
396
+ color: var(--text);
397
+ }
398
+
399
+ .report-meta {
400
+ margin-top: 4px;
401
+ font-family: var(--mono);
402
+ font-size: 11.5px;
403
+ color: var(--text-muted);
404
+ display: flex; flex-wrap: wrap;
405
+ align-items: baseline;
406
+ gap: 4px;
407
+ }
408
+ .report-meta .sep { color: var(--text-faint); margin: 0 2px; }
409
+ .report-meta-k {
410
+ text-transform: uppercase;
411
+ font-size: 10px;
412
+ letter-spacing: 0.05em;
413
+ color: var(--text-faint);
414
+ }
415
+ .report-meta-v { color: var(--text); }
416
+
417
+ /* Tier block */
418
+ .tier-block {
419
+ display: grid;
420
+ grid-template-columns: 64px 1fr;
421
+ gap: 14px;
422
+ align-items: center;
423
+ padding: 14px 16px;
424
+ border-bottom: 1px solid var(--line);
425
+ background: #fff;
426
+ }
427
+
428
+ .tier-badge {
429
+ width: 64px; height: 64px;
430
+ border-radius: 6px;
431
+ display: flex; flex-direction: column;
432
+ align-items: center; justify-content: center;
433
+ color: #fff;
434
+ font-family: var(--mono);
435
+ line-height: 1;
436
+ }
437
+ .tier-badge-num {
438
+ font-size: 30px;
439
+ font-weight: 700;
440
+ letter-spacing: -0.02em;
441
+ }
442
+ .tier-badge-of {
443
+ font-size: 10px;
444
+ font-weight: 500;
445
+ letter-spacing: 0.06em;
446
+ text-transform: uppercase;
447
+ margin-top: 2px;
448
+ opacity: 0.9;
449
+ }
450
+
451
+ .tier-pending { background: var(--text-faint); }
452
+ .tier-badge.t-1 { background: var(--nyc-scarlet); }
453
+ .tier-badge.t-2 { background: #d97706; }
454
+ .tier-badge.t-3 { background: #ca8a04; }
455
+ .tier-badge.t-4 { background: var(--nyc-blue); }
456
+ .tier-badge.t-0 { background: var(--good); }
457
+
458
+ .tier-text { min-width: 0; }
459
+ .tier-label {
460
+ font-size: 14px;
461
+ font-weight: 600;
462
+ color: var(--text);
463
+ line-height: 1.2;
464
+ }
465
+ .tier-help {
466
+ margin-top: 3px;
467
+ font-size: 12px;
468
+ color: var(--text-muted);
469
+ line-height: 1.35;
470
+ }
471
+
472
+ /* Section headings (small civic-style) */
473
+ .report-section-h {
474
+ font-size: 10px;
475
+ font-weight: 700;
476
+ text-transform: uppercase;
477
+ letter-spacing: 0.10em;
478
+ color: var(--text-muted);
479
+ padding: 12px 16px 6px;
480
+ display: flex;
481
+ align-items: baseline;
482
+ }
483
+ .hint-inline {
484
+ font-weight: 400;
485
+ text-transform: none;
486
+ letter-spacing: 0;
487
+ color: var(--text-faint);
488
+ margin-left: 6px;
489
+ font-size: 11px;
490
+ }
491
+
492
+ /* Summary lede */
493
+ .summary-box {
494
+ border-bottom: 1px solid var(--line);
495
+ background: var(--nyc-blue-soft);
496
+ }
497
+ .summary-box #paragraph {
498
+ margin: 0;
499
+ padding: 0 16px 14px;
500
+ font-size: 13.5px;
501
+ line-height: 1.55;
502
+ color: var(--text);
503
+ }
504
+ .summary-box #paragraph .cite {
505
+ display: inline-block;
506
+ vertical-align: super;
507
+ font-size: 9.5px;
508
+ line-height: 1;
509
+ font-family: var(--mono);
510
+ color: var(--nyc-blue);
511
+ background: #fff;
512
+ border: 1px solid rgba(22, 66, 223, 0.30);
513
+ padding: 1px 4px;
514
+ margin-left: 1px;
515
+ border-radius: 3px;
516
+ cursor: help;
517
+ font-weight: 500;
518
+ }
519
+
520
+ /* Key findings dl */
521
+ .key-findings { border-bottom: 1px solid var(--line); }
522
+ #keyFindings {
523
+ margin: 0;
524
+ padding: 0 16px 14px;
525
+ display: grid;
526
+ grid-template-columns: minmax(140px, max-content) 1fr;
527
+ column-gap: 14px;
528
+ row-gap: 6px;
529
+ font-size: 12.5px;
530
+ }
531
+ #keyFindings dt {
532
+ color: var(--text-muted);
533
+ font-size: 11px;
534
+ text-transform: uppercase;
535
+ letter-spacing: 0.04em;
536
+ padding-top: 1px;
537
+ }
538
+ #keyFindings dd {
539
+ margin: 0;
540
+ color: var(--text);
541
+ font-family: var(--mono);
542
+ font-size: 12px;
543
+ word-break: break-word;
544
+ }
545
+ #keyFindings dd.hit { color: var(--nyc-scarlet); font-weight: 600; }
546
+ #keyFindings dd.miss { color: var(--text-faint); }
547
+
548
+ /* Evidence cards */
549
+ .evidence-stack { border-bottom: 1px solid var(--line); padding-bottom: 10px; }
550
+ #evidenceCards {
551
+ display: flex; flex-direction: column;
552
+ gap: 8px;
553
+ padding: 0 16px 4px;
554
+ }
555
+
556
+ .ec {
557
+ border: 1px solid var(--line);
558
+ border-radius: 4px;
559
+ background: #fff;
560
+ overflow: hidden;
561
+ }
562
+ .ec-head {
563
+ display: flex; align-items: center; justify-content: space-between;
564
+ padding: 7px 11px;
565
+ background: var(--bg-soft);
566
+ border-bottom: 1px solid var(--line);
567
+ cursor: pointer;
568
+ user-select: none;
569
+ }
570
+ .ec-title {
571
+ font-size: 12px;
572
+ font-weight: 600;
573
+ color: var(--text);
574
+ display: flex; align-items: center; gap: 8px;
575
+ }
576
+ .ec-title .ec-flag {
577
+ width: 8px; height: 8px; border-radius: 50%;
578
+ display: inline-block;
579
+ }
580
+ .ec-flag.hit { background: var(--nyc-scarlet); }
581
+ .ec-flag.note { background: var(--nyc-blue); }
582
+ .ec-flag.miss { background: var(--text-faint); }
583
+
584
+ .ec-tag {
585
+ font-family: var(--mono);
586
+ font-size: 9.5px;
587
+ text-transform: uppercase;
588
+ letter-spacing: 0.04em;
589
+ color: var(--text-muted);
590
+ }
591
+ .ec-body { display: block; padding: 8px 11px 10px; font-size: 12px; }
592
+ .ec-body dl {
593
+ margin: 0;
594
+ display: grid;
595
+ grid-template-columns: minmax(120px, max-content) 1fr;
596
+ column-gap: 12px;
597
+ row-gap: 4px;
598
+ }
599
+ .ec-body dt {
600
+ color: var(--text-muted);
601
+ font-size: 10.5px;
602
+ text-transform: uppercase;
603
+ letter-spacing: 0.04em;
604
+ }
605
+ .ec-body dd {
606
+ margin: 0;
607
+ font-family: var(--mono);
608
+ font-size: 11.5px;
609
+ color: var(--text);
610
+ }
611
+ .ec-foot {
612
+ font-size: 10px;
613
+ color: var(--text-faint);
614
+ font-family: var(--mono);
615
+ padding: 4px 11px 8px;
616
+ border-top: 1px dotted var(--line);
617
+ }
618
+ .ec-foot a { color: var(--nyc-blue); }
619
+
620
+ .ec.collapsed .ec-body,
621
+ .ec.collapsed .ec-foot { display: none; }
622
+ .ec-toggle {
623
+ font-family: var(--mono);
624
+ font-size: 11px;
625
+ color: var(--text-muted);
626
+ }
627
+
628
+ /* Policy context */
629
+ .policy-stack { border-bottom: 1px solid var(--line); padding-bottom: 12px; }
630
+ #policyList {
631
+ list-style: none;
632
+ margin: 0;
633
+ padding: 0 16px;
634
+ display: grid; gap: 8px;
635
+ }
636
+ #policyList li {
637
+ border-left: 3px solid var(--nyc-blue);
638
+ background: var(--nyc-blue-soft);
639
+ padding: 8px 12px;
640
+ border-radius: 0 3px 3px 0;
641
+ }
642
+ .policy-title {
643
+ font-size: 11.5px;
644
+ font-weight: 600;
645
+ color: var(--nyc-blue-dark);
646
+ margin-bottom: 3px;
647
+ }
648
+ .policy-quote {
649
+ font-size: 12px;
650
+ line-height: 1.45;
651
+ color: var(--text);
652
+ font-style: italic;
653
+ }
654
+ .policy-quote::before { content: "\201C"; }
655
+ .policy-quote::after { content: "\201D"; }
656
+ .policy-cite {
657
+ margin-top: 4px;
658
+ font-size: 10.5px;
659
+ color: var(--text-muted);
660
+ font-family: var(--mono);
661
+ }
662
+
663
+ /* Energy footer */
664
+ .energy-footer {
665
+ padding: 8px 16px;
666
+ font-size: 11px;
667
+ color: var(--text-muted);
668
+ font-family: var(--mono);
669
+ border-bottom: 1px solid var(--line);
670
+ display: flex; flex-wrap: wrap; gap: 4px;
671
+ align-items: baseline;
672
+ }
673
+ .energy-num-inline {
674
+ font-weight: 700;
675
+ color: var(--nyc-blue-dark);
676
+ }
677
+ .energy-footer .sep { color: var(--text-faint); margin: 0 2px; }
678
+
679
+ /* Methodology disclosure */
680
+ .methodology {
681
+ font-size: 11.5px;
682
+ }
683
+ .methodology summary {
684
+ padding: 9px 16px;
685
+ font-weight: 600;
686
+ color: var(--nyc-blue);
687
+ cursor: pointer;
688
+ list-style: none;
689
+ font-size: 11px;
690
+ text-transform: uppercase;
691
+ letter-spacing: 0.06em;
692
+ }
693
+ .methodology summary::-webkit-details-marker { display: none; }
694
+ .methodology summary::before {
695
+ content: "▸";
696
+ display: inline-block;
697
+ margin-right: 6px;
698
+ transition: transform 0.15s;
699
+ }
700
+ .methodology[open] summary::before { transform: rotate(90deg); }
701
+ .method-body {
702
+ padding: 4px 16px 14px;
703
+ color: var(--text-muted);
704
+ font-size: 11.5px;
705
+ line-height: 1.5;
706
+ }
707
+ .method-body p { margin: 0 0 8px; }
708
+ .method-body h4 {
709
+ margin: 12px 0 4px;
710
+ font-size: 10px;
711
+ text-transform: uppercase;
712
+ letter-spacing: 0.10em;
713
+ color: var(--text-muted);
714
+ font-weight: 700;
715
+ }
716
+ .method-body ol {
717
+ margin: 0;
718
+ padding-left: 16px;
719
+ font-family: var(--mono);
720
+ font-size: 11px;
721
+ display: grid; gap: 4px;
722
+ }
723
+ .method-body ol li { padding-left: 4px; }
724
+ .method-body code {
725
+ background: var(--bg-soft);
726
+ padding: 1px 4px;
727
+ border-radius: 2px;
728
+ font-size: 10.5px;
729
+ }
730
+
731
+ /* Methodology footer's numbered source list */
732
+ .method-body ol li {
733
+ list-style: decimal;
734
+ word-break: break-word;
735
+ }
736
+
737
+ /* Legacy section padding for compare.js / register.js panels */
738
+ .report-section { padding: 12px 16px 14px; border-bottom: 1px solid var(--line); }
739
+ .report-section:last-child { border-bottom: 0; }
740
+ .report-section h3 {
741
+ margin: 0 0 8px;
742
+ font-size: 10.5px; font-weight: 700;
743
+ text-transform: uppercase; letter-spacing: 0.10em;
744
+ color: var(--text-muted);
745
+ }
746
+
747
+ /* Legacy classes still used by compare.js and register.js (will be
748
+ migrated to the civic-assessment structure in a follow-up). Keep
749
+ minimal styling so those pages still render. */
750
+ .glance-list { list-style: none; margin: 0; padding: 0;
751
+ display: grid; grid-template-columns: 1fr; gap: 4px; font-size: 12.5px; }
752
+ .glance-list li { display: grid; grid-template-columns: 18px 1fr;
753
+ align-items: baseline; gap: 6px; padding: 3px 0; }
754
+ .glance-list li.hit { color: var(--text); }
755
+ .glance-list li.miss { color: var(--text-faint); }
756
+ .glance-list .gmark { font-family: var(--mono); font-weight: 700; line-height: 1; font-size: 13px; }
757
+ .glance-list .hit .gmark { color: var(--nyc-scarlet); }
758
+ .glance-list .miss .gmark { color: var(--text-faint); }
759
+ .glance-list .note .gmark { color: var(--nyc-blue); }
760
+ .glance-list .gnum { font-family: var(--mono); font-weight: 500; color: var(--text); }
761
+
762
+ .sources-list { list-style: none; margin: 0; padding: 0; font-size: 11.5px;
763
+ display: grid; gap: 4px; }
764
+ .sources-list li { display: grid; grid-template-columns: max-content 1fr;
765
+ gap: 8px; align-items: baseline; padding: 2px 0;
766
+ border-bottom: 1px dotted var(--line); }
767
+ .sources-list li:last-child { border-bottom: 0; }
768
+ .sources-list .src-tag { font-family: var(--mono); font-size: 10.5px;
769
+ color: var(--nyc-blue); background: var(--nyc-blue-soft);
770
+ border: 1px solid rgba(22,66,223,0.25); border-radius: 3px; padding: 1px 5px;
771
+ white-space: nowrap; font-weight: 500; }
772
+ .sources-list .src-cite { color: var(--text-muted); line-height: 1.4; }
773
+
774
+ /* Compare + register pages use simple #paragraph .cite — preserve that style */
775
+ .compare-mode #paragraph .cite,
776
+ .register-mode #paragraph .cite,
777
+ .report-section .cite,
778
+ #detailParagraph .cite {
779
+ display: inline-block; padding: 1px 5px; margin-left: 1px; border-radius: 3px;
780
+ background: var(--nyc-blue-soft); color: var(--nyc-blue);
781
+ font-family: var(--mono); font-size: 10.5px; font-weight: 500;
782
+ border: 1px solid rgba(22,66,223,0.25); cursor: help;
783
+ }
784
+
785
+ /* ----- Footer ----- */
786
+
787
+ footer {
788
+ border-top: 1px solid var(--line);
789
+ background: #fff;
790
+ margin-top: 28px;
791
+ }
792
+
793
+ .foot-inner {
794
+ max-width: 1480px;
795
+ margin: 0 auto;
796
+ padding: 22px 20px 36px;
797
+ display: grid;
798
+ grid-template-columns: 1.5fr 1fr;
799
+ gap: 32px;
800
+ }
801
+
802
+ footer h3 {
803
+ margin: 0 0 6px;
804
+ font-size: 11px;
805
+ font-weight: 700;
806
+ text-transform: uppercase;
807
+ letter-spacing: 0.10em;
808
+ color: var(--text-muted);
809
+ }
810
+
811
+ footer p {
812
+ margin: 0;
813
+ font-size: 12.5px;
814
+ line-height: 1.55;
815
+ color: var(--text-muted);
816
+ }
817
+
818
+ footer a { color: var(--nyc-blue); }
819
+
820
+ .topbar-select {
821
+ padding: 4px 8px;
822
+ font: inherit;
823
+ font-size: 12px;
824
+ color: var(--text);
825
+ background: #fff;
826
+ border: 1px solid var(--line-strong);
827
+ border-radius: 3px;
828
+ margin-right: 12px;
829
+ }
830
+
831
+ /* ----- Compare mode ----- */
832
+
833
+ .modelink {
834
+ font-size: 12px;
835
+ color: var(--text-muted);
836
+ margin-right: 12px;
837
+ }
838
+ .modelink:hover { color: var(--nyc-blue); text-decoration: underline; }
839
+
840
+ .form-bar-compare #cform {
841
+ max-width: 1640px;
842
+ margin: 0 auto;
843
+ display: grid;
844
+ grid-template-columns: 1fr 1fr auto;
845
+ gap: 8px;
846
+ align-items: end;
847
+ }
848
+
849
+ .cform-row {
850
+ display: flex; flex-direction: column;
851
+ gap: 3px;
852
+ }
853
+
854
+ .cform-row label {
855
+ font-size: 10px;
856
+ font-weight: 700;
857
+ text-transform: uppercase;
858
+ letter-spacing: 0.10em;
859
+ color: var(--text-muted);
860
+ }
861
+
862
+ .compare-workbench {
863
+ grid-template-columns: 360px minmax(0, 1fr) 360px;
864
+ }
865
+
866
+ .cpane {
867
+ display: flex; flex-direction: column; gap: 12px;
868
+ position: sticky; top: calc(var(--topbar-h) + 14px);
869
+ max-height: calc(100vh - var(--topbar-h) - 28px);
870
+ overflow: auto;
871
+ }
872
+
873
+ .cpane-head {
874
+ display: flex; align-items: center; gap: 10px;
875
+ padding: 8px 12px;
876
+ background: #fff;
877
+ border: 1px solid var(--line);
878
+ border-radius: 4px;
879
+ font-size: 12.5px;
880
+ font-weight: 500;
881
+ color: var(--text);
882
+ word-break: break-word;
883
+ }
884
+
885
+ .ctag {
886
+ display: inline-flex;
887
+ align-items: center; justify-content: center;
888
+ width: 22px; height: 22px;
889
+ font-size: 12px;
890
+ font-weight: 700;
891
+ color: #fff;
892
+ border-radius: 4px;
893
+ flex-shrink: 0;
894
+ }
895
+ .ctag.a { background: var(--nyc-blue); }
896
+ .ctag.b { background: #9333ea; }
897
+
898
+ .compare-mid #map {
899
+ height: calc(100vh - var(--topbar-h) - 200px);
900
+ min-height: 500px;
901
+ }
902
+
903
+ @media (max-width: 1340px) {
904
+ .compare-workbench { grid-template-columns: 1fr 1fr; }
905
+ .cpane { position: static; max-height: none; }
906
+ .compare-mid { grid-column: 1 / -1; order: -1; }
907
+ }
908
+
909
+ @media (max-width: 980px) {
910
+ .compare-workbench { grid-template-columns: 1fr; }
911
+ .form-bar-compare #cform { grid-template-columns: 1fr; }
912
+ }
913
+
914
+ /* ----- Register / Bulk mode ----- */
915
+
916
+ .register-summary {
917
+ max-width: 1640px;
918
+ margin: 0 auto;
919
+ padding: 14px 20px;
920
+ display: flex; gap: 22px; align-items: center;
921
+ background: #fff;
922
+ border-bottom: 1px solid var(--line);
923
+ flex-wrap: wrap;
924
+ }
925
+
926
+ .reg-stat {
927
+ display: flex; flex-direction: column;
928
+ border-right: 1px solid var(--line);
929
+ padding-right: 22px;
930
+ }
931
+ .reg-stat:last-of-type { border-right: 0; padding-right: 0; }
932
+ .reg-stat-num {
933
+ font-family: var(--mono);
934
+ font-size: 22px;
935
+ font-weight: 700;
936
+ line-height: 1.05;
937
+ color: var(--text);
938
+ }
939
+ .reg-stat-lbl {
940
+ font-size: 11px;
941
+ text-transform: uppercase;
942
+ letter-spacing: 0.08em;
943
+ color: var(--text-muted);
944
+ }
945
+ .reg-stat.tier-1 .reg-stat-num { color: var(--nyc-scarlet); }
946
+ .reg-stat.tier-2 .reg-stat-num { color: #d97706; }
947
+ .reg-stat.tier-3 .reg-stat-num { color: var(--nyc-blue); }
948
+
949
+ .reg-stat-spacer { flex: 1; }
950
+
951
+ .reg-controls {
952
+ display: flex; gap: 8px; align-items: center;
953
+ }
954
+
955
+ .reg-controls input,
956
+ .reg-controls select {
957
+ padding: 7px 10px;
958
+ font: inherit;
959
+ font-size: 13px;
960
+ border: 1px solid var(--line-strong);
961
+ border-radius: 3px;
962
+ outline: none;
963
+ }
964
+
965
+ .reg-controls input { width: 280px; }
966
+ .reg-controls input:focus,
967
+ .reg-controls select:focus {
968
+ border-color: var(--nyc-blue);
969
+ box-shadow: 0 0 0 2px rgba(22, 66, 223, 0.16);
970
+ }
971
+
972
+ .btn-secondary {
973
+ background: #fff;
974
+ color: var(--nyc-blue);
975
+ border: 1px solid var(--nyc-blue);
976
+ padding: 7px 14px;
977
+ font-size: 13px;
978
+ }
979
+ .btn-secondary:hover:not(:disabled) {
980
+ background: var(--nyc-blue-soft);
981
+ color: var(--nyc-blue-dark);
982
+ }
983
+
984
+ .register-workbench {
985
+ max-width: 1640px;
986
+ margin: 0 auto;
987
+ padding: 14px 20px;
988
+ display: grid;
989
+ grid-template-columns: minmax(0, 1fr) 460px;
990
+ gap: 14px;
991
+ align-items: start;
992
+ }
993
+
994
+ .reg-table-wrap { padding: 0; overflow: hidden; }
995
+ .reg-tablescroll { overflow: auto; max-height: calc(100vh - var(--topbar-h) - 200px); }
996
+
997
+ #regTable {
998
+ width: 100%;
999
+ border-collapse: collapse;
1000
+ font-size: 13px;
1001
+ }
1002
+
1003
+ #regTable thead {
1004
+ position: sticky; top: 0; z-index: 1;
1005
+ background: var(--bg-soft);
1006
+ }
1007
+
1008
+ #regTable th {
1009
+ text-align: left;
1010
+ font-size: 11px;
1011
+ font-weight: 700;
1012
+ text-transform: uppercase;
1013
+ letter-spacing: 0.08em;
1014
+ color: var(--text-muted);
1015
+ padding: 10px 14px;
1016
+ border-bottom: 1px solid var(--line);
1017
+ }
1018
+ #regTable th.num { text-align: right; }
1019
+
1020
+ #regTable td {
1021
+ padding: 10px 14px;
1022
+ border-bottom: 1px solid var(--line);
1023
+ vertical-align: top;
1024
+ }
1025
+ #regTable td.num { text-align: right; font-family: var(--mono); }
1026
+
1027
+ #regTable tbody tr { cursor: pointer; }
1028
+ #regTable tbody tr:hover { background: var(--nyc-blue-soft); }
1029
+
1030
+ .tier-badge {
1031
+ display: inline-block;
1032
+ width: 24px; height: 24px;
1033
+ line-height: 22px;
1034
+ text-align: center;
1035
+ font-family: var(--mono);
1036
+ font-size: 12px;
1037
+ font-weight: 700;
1038
+ border-radius: 3px;
1039
+ border: 1px solid;
1040
+ color: #fff;
1041
+ }
1042
+ .tier-badge.tier-1 { background: var(--nyc-scarlet); border-color: var(--nyc-scarlet); }
1043
+ .tier-badge.tier-2 { background: #d97706; border-color: #d97706; }
1044
+ .tier-badge.tier-3 { background: var(--nyc-blue); border-color: var(--nyc-blue); }
1045
+
1046
+ .rname { font-weight: 600; color: var(--text); }
1047
+ .raddr { color: var(--text-muted); font-size: 11.5px; margin-top: 2px; }
1048
+ .rmeta {
1049
+ color: var(--text-muted); font-size: 11.5px; font-family: var(--mono);
1050
+ margin-top: 4px;
1051
+ }
1052
+
1053
+ .yn { font-family: var(--mono); font-size: 14px; line-height: 1; }
1054
+ .yn.yes { color: var(--nyc-scarlet); }
1055
+ .yn.no { color: var(--text-faint); }
1056
+
1057
+ .reg-detail {
1058
+ position: sticky; top: calc(var(--topbar-h) + 14px);
1059
+ max-height: calc(100vh - var(--topbar-h) - 28px);
1060
+ overflow: auto;
1061
+ }
1062
+
1063
+ .reg-detail-empty {
1064
+ padding: 18px;
1065
+ color: var(--text-muted);
1066
+ font-size: 13.5px;
1067
+ }
1068
+
1069
+ .reg-detail-header {
1070
+ padding: 14px 16px;
1071
+ border-bottom: 1px solid var(--line);
1072
+ background: var(--bg-soft);
1073
+ }
1074
+
1075
+ #detailMap {
1076
+ width: 100%;
1077
+ height: 220px;
1078
+ border-bottom: 1px solid var(--line);
1079
+ }
1080
+
1081
+ @media (max-width: 1100px) {
1082
+ .register-workbench { grid-template-columns: 1fr; }
1083
+ .reg-detail { position: static; max-height: none; }
1084
+ }
1085
+
1086
+ /* ----- Responsive guardrails ----- */
1087
+
1088
+ @media (max-width: 1340px) {
1089
+ .workbench { grid-template-columns: 280px minmax(0, 1fr); }
1090
+ .col-right { position: static; max-height: none; grid-column: 1 / -1; }
1091
+ }
1092
+
1093
+ @media (max-width: 980px) {
1094
+ .workbench { grid-template-columns: 1fr; }
1095
+ .col-left { position: static; }
1096
+ .col-right { position: static; }
1097
+ .foot-inner { grid-template-columns: 1fr; }
1098
+ }
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Bold.woff ADDED
Binary file (78.2 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Bold.woff2 ADDED
Binary file (56.6 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-BoldItalic.woff ADDED
Binary file (83.8 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-BoldItalic.woff2 ADDED
Binary file (60.5 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Italic.woff ADDED
Binary file (84.2 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Italic.woff2 ADDED
Binary file (60.8 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Regular.woff ADDED
Binary file (78.7 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Regular.woff2 ADDED
Binary file (56.5 kB). View file
 
web/static/vendor/nyco/fonts/IBM-Plex-Sans/license.txt ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font creation
14
+ efforts of academic and linguistic communities, and to provide a free and
15
+ open framework in which fonts may be shared and improved in partnership
16
+ with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply
25
+ to any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software components as
36
+ distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to, deleting,
39
+ or substituting -- in part or in whole -- any of the components of the
40
+ Original Version, by changing formats or by porting the Font Software to a
41
+ new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION & CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
49
+ redistribute, and sell modified and unmodified copies of the Font
50
+ Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components,
53
+ in Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the corresponding
64
+ Copyright Holder. This restriction only applies to the primary font name as
65
+ presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created
77
+ using the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
web/static/vendor/nyco/styles/nyco.css ADDED
The diff for this file is too large to render. See raw diff