FastAPI primary UI + NYCO design system vendor drop
Browse filesweb/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 +0 -0
- web/main.py +254 -0
- web/static/app.js +682 -0
- web/static/index.html +200 -0
- web/static/style.css +1098 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Bold.woff +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Bold.woff2 +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-BoldItalic.woff +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-BoldItalic.woff2 +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Italic.woff +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Italic.woff2 +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Regular.woff +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/IBMPlexSans-Regular.woff2 +0 -0
- web/static/vendor/nyco/fonts/IBM-Plex-Sans/license.txt +92 -0
- web/static/vendor/nyco/styles/nyco.css +0 -0
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 & 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 m, last 5 yr: +1. FloodNet sensor with ≥1 event in 400 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 Wh, an estimated order of magnitude below the
|
| 171 |
+
~0.3 Wh-per-query figure
|
| 172 |
+
<a href="https://epoch.ai/gradient-updates/how-much-energy-does-chatgpt-use" target="_blank">Epoch 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
|
|
|