// Riprap agent client — three-panel UI with live SSE streaming, intent- // dispatched map, and structured report rendering. const $ = (s) => document.querySelector(s); const STEP_LABELS = { // single_address chain (linear FSM) geocode: ["Geocode (DCP Geosearch)", "address → lat/lon, BBL"], sandy_inundation: ["Sandy Inundation (NYC OD)", "empirical 2012 extent"], dep_stormwater: ["DEP Stormwater Maps", "pluvial scenarios + 2080 SLR"], floodnet: ["FloodNet sensor network", "live ultrasonic depth sensors"], nyc311: ["NYC 311 archive", "flood complaints in 200m"], noaa_tides: ["NOAA Tides & Currents (live)", "Battery / Kings Pt / Sandy Hook"], nws_alerts: ["NWS Public Alerts (live)", "active flood-relevant alerts"], nws_obs: ["NWS METAR observation (live)", "nearest ASOS recent precipitation"], ttm_forecast: ["Granite TTM r2 — surge nowcast", "9.6h forecast at the closest of Battery / Kings Pt / Sandy Hook"], ttm_311_forecast: ["Granite TTM r2 — 311 forecast", "4-week per-address flood-complaint forecast (52w history)"], floodnet_forecast: ["Granite TTM r2 — FloodNet forecast", "flood-event recurrence forecast at nearest FloodNet sensor"], ttm_battery_surge: ["Granite TTM r2 — Battery surge (NYC fine-tune)", "96 h hourly surge nowcast at NOAA Battery (msradam/Granite-TTM-r2-Battery-Surge)"], mta_entrance_exposure: ["MTA subway entrances", "subway-entrance exposure (point-in-polygon Sandy + DEP)"], nycha_development_exposure: ["NYCHA developments", "NYCHA campus footprint × Sandy + DEP overlap %"], doe_school_exposure: ["NYC DOE schools", "school-point exposure (Sandy + DEP)"], doh_hospital_exposure: ["NYS DOH hospitals", "Article-28 hospital exposure (Sandy + DEP)"], microtopo_lidar: ["LiDAR terrain (DEM + TWI + HAND)", "USGS 3DEP DEM + whitebox-workflows"], ida_hwm_2021: ["Ida 2021 high-water marks", "USGS empirical post-event extent"], prithvi_eo_v2: ["Prithvi-EO 2.0 (NASA/IBM)", "Sen1Floods11 satellite segmentation"], prithvi_eo_live: ["Prithvi-EO 2.0 — live segmentation","fresh Sentinel-2 water mask at this address"], terramind_synthesis: ["TerraMind 1.0 base — synthetic LULC", "DEM → ESRI Land Cover, any-to-any generative synthesis (IBM/ESA)"], eo_chip_fetch: ["EO chip fetch (S2L2A + S1RTC + DEM)", "single-chip cache for the TerraMind-NYC LoRA family"], terramind_lulc: ["TerraMind-NYC — LULC (live)", "5-class macro land-cover LoRA (msradam/TerraMind-NYC-Adapters)"], terramind_buildings: ["TerraMind-NYC — Buildings (live)", "binary building-footprint LoRA (msradam/TerraMind-NYC-Adapters)"], rag_granite_embedding: ["Granite Embedding 278M (RAG)", "policy corpus retrieval (+ Granite Reranker R2 if enabled)"], gliner_extract: ["GLiNER typed extraction", "agencies, dollar amounts, projects, locations"], reconcile_granite41: ["Granite 4.1 reconcile (local)", "document-grounded synthesis"], // neighborhood + dev_check nta_resolve: ["NTA polygon resolve", "name → NYC NTA 2020 polygon"], sandy_nta: ["Sandy 2012, polygon-aggregated", "% of NTA inside 2012 inundation"], dep_extreme_2080_nta: ["DEP Extreme-2080, polygon", "% of NTA in modeled flooding"], dep_moderate_2050_nta: ["DEP Moderate-2050, polygon", "% of NTA in modeled flooding"], dep_moderate_current_nta:["DEP Moderate-current, polygon", "% of NTA in modeled flooding"], nyc311_nta: ["NYC 311, polygon-aggregated", "complaints inside polygon"], microtopo_nta: ["LiDAR terrain, polygon", "median HAND/TWI + flood bands"], rag_nta: ["Granite Embedding RAG (NTA)", "policy retrieval for the place"], reconcile_neighborhood: ["Granite 4.1 reconcile (NTA)", "polygon-flavored briefing"], // dev_check dob_permits_nta: ["NYC DOB permits in polygon", "active NB / A1 / DM jobs ↔ flood layers"], rag_dev: ["Granite Embedding RAG (dev)", "policy on new construction in flood zones"], reconcile_development: ["Granite 4.1 reconcile (dev)", "flagged-projects briefing"], // live_now reconcile_live_now: ["Granite 4.1 reconcile (live)", "current-conditions briefing"], }; const SOURCE_LABELS = { geocode: "NYC DCP Geosearch", nta_resolve: "NYC DCP Neighborhood Tabulation Areas 2020", sandy: "NYC OD 5xsi-dfpx — Sandy 2012 inundation", sandy_nta: "Sandy 2012 inundation, polygon-aggregated", dep_extreme_2080: "NYC DEP Stormwater — Extreme-2080", dep_moderate_2050: "NYC DEP Stormwater — Moderate-2050", dep_moderate_current: "NYC DEP Stormwater — Moderate-current", dep_extreme_2080_nta: "NYC DEP Extreme-2080, polygon-aggregated", dep_moderate_2050_nta: "NYC DEP Moderate-2050, polygon-aggregated", dep_moderate_current_nta: "NYC DEP Moderate-current, polygon-aggregated", floodnet: "FloodNet NYC", nyc311: "NYC 311 (erm2-nwe9)", nyc311_nta: "NYC 311, polygon-aggregated", microtopo: "USGS 3DEP DEM", microtopo_nta: "USGS 3DEP DEM, polygon-aggregated", ida_hwm: "USGS Hurricane Ida 2021 HWMs", prithvi_water: "Prithvi-EO 2.0 — Hurricane Ida 2021 polygons", prithvi_live: "Prithvi-EO 2.0 NYC-Pluvial v2 — live Sentinel-2 water segmentation (msradam/Prithvi-EO-2.0-NYC-Pluvial)", terramind_synthetic: "TerraMind 1.0 base — synthetic LULC (DEM→ESRI Land Cover)", tm_lulc: "TerraMind-NYC LULC LoRA (msradam/TerraMind-NYC-Adapters)", tm_buildings: "TerraMind-NYC Buildings LoRA (msradam/TerraMind-NYC-Adapters)", gliner_comptroller: "GLiNER over Comptroller report", gliner_dep_2013: "GLiNER over DEP wastewater plan", gliner_nycha: "GLiNER over NYCHA Lessons Learned", gliner_mta: "GLiNER over MTA Climate Resilience Roadmap", gliner_coned: "GLiNER over Con Edison Climate Resilience", noaa_tides: "NOAA CO-OPS Tides & Currents", nws_alerts: "NWS Public Alerts", nws_obs: "NWS Station Observations", ttm_forecast: "Granite TimeSeries TTM r2 — surge residual nowcast", ttm_311_forecast: "Granite TimeSeries TTM r2 — per-address 311 weekly forecast", floodnet_forecast: "Granite TimeSeries TTM r2 — FloodNet sensor recurrence forecast", ttm_battery: "Granite TTM r2 NYC fine-tune — 96 h Battery surge nowcast (msradam/Granite-TTM-r2-Battery-Surge)", dob_permits: "NYC DOB Permit Issuance (Socrata ipu4-2q9a)", live_target: "Riprap planner — live target", rag_comptroller: 'NYC Comptroller — "Is NYC Ready for Rain?" (2024)', rag_npcc4: "NPCC4 (2024)", rag_mta: "MTA Climate Resilience Roadmap", rag_nycha: "NYCHA Flood Resilience: Lessons Learned", rag_coned: "Con Edison Climate Resilience Plan", // Register-specialist family labels — chip lookups for dynamic // doc_ids (mta_entrance_, nycha_dev_, doe_school_, // nyc_hospital_) fall through to these via family-prefix match. mta_entrance: "MTA subway-entrance exposure (Open Data)", nycha_dev: "NYCHA development exposure (NYC OD phvi-damg)", doe_school: "NYC DOE school exposure", nyc_hospital: "NYS DOH hospital exposure (vn5v-hh5r)", }; // Canonical URL per doc_id — clicking a source row opens the underlying // dataset / API / report in a new tab so users can verify provenance. const SOURCE_URLS = { geocode: "https://geosearch.planninglabs.nyc/", nta_resolve: "https://www.nyc.gov/site/planning/data-maps/open-data/dwn-nynta.page", sandy: "https://data.cityofnewyork.us/Environment/Sandy-Inundation-Zone/uyj8-7rv5", sandy_nta: "https://data.cityofnewyork.us/Environment/Sandy-Inundation-Zone/uyj8-7rv5", dep_extreme_2080: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Extreme-Flood-with-Curren/w8eg-8ha6", dep_moderate_2050: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood-with-Curre/9i7c-xyvv", dep_moderate_current: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood/5rzh-cyqd", dep_extreme_2080_nta: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Extreme-Flood-with-Curren/w8eg-8ha6", dep_moderate_2050_nta: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood-with-Curre/9i7c-xyvv", dep_moderate_current_nta: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood/5rzh-cyqd", floodnet: "https://www.floodnet.nyc/", nyc311: "https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9", nyc311_nta: "https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9", microtopo: "https://www.usgs.gov/3d-elevation-program", microtopo_nta: "https://www.usgs.gov/3d-elevation-program", ida_hwm: "https://stn.wim.usgs.gov/STNDataPortal/", prithvi_water: "https://huggingface.co/ibm-nasa-geospatial/Prithvi-EO-2.0-300M-TL-Sen1Floods11", prithvi_live: "https://huggingface.co/msradam/Prithvi-EO-2.0-NYC-Pluvial", terramind_synthetic: "https://huggingface.co/ibm-esa-geospatial/TerraMind-1.0-base", tm_lulc: "https://huggingface.co/msradam/TerraMind-NYC-Adapters", tm_buildings: "https://huggingface.co/msradam/TerraMind-NYC-Adapters", gliner_comptroller: "https://huggingface.co/urchade/gliner_medium-v2.1", gliner_dep_2013: "https://huggingface.co/urchade/gliner_medium-v2.1", gliner_nycha: "https://huggingface.co/urchade/gliner_medium-v2.1", gliner_mta: "https://huggingface.co/urchade/gliner_medium-v2.1", gliner_coned: "https://huggingface.co/urchade/gliner_medium-v2.1", noaa_tides: "https://tidesandcurrents.noaa.gov/", nws_alerts: "https://www.weather.gov/documentation/services-web-api", nws_obs: "https://www.weather.gov/documentation/services-web-api", ttm_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2", ttm_311_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2", floodnet_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2", ttm_battery: "https://huggingface.co/msradam/Granite-TTM-r2-Battery-Surge", dob_permits: "https://data.cityofnewyork.us/Housing-Development/DOB-Permit-Issuance/ipu4-2q9a", rag_comptroller: "https://comptroller.nyc.gov/reports/is-new-york-city-ready-for-rain/", rag_npcc4: "https://nyaspubs.onlinelibrary.wiley.com/toc/17496632/2024/1539/1", rag_mta: "https://new.mta.info/sustainability/climate-resilience", rag_nycha: "https://www.nyc.gov/site/nycha/about/sustainability.page", rag_coned: "https://www.coned.com/en/our-energy-future/climate-change-resilience", mta_entrance: "https://data.ny.gov/Transportation/MTA-Subway-Entrances-and-Exits-2024/i9wp-a4ja", nycha_dev: "https://data.cityofnewyork.us/Housing-Development/Map-of-NYCHA-Developments/i9rv-hdr5", doe_school: "https://data.cityofnewyork.us/Education/School-Locations/jfju-ynrr", nyc_hospital: "https://health.data.ny.gov/Health/Health-Facility-Certification-Information/2g9y-7kqm", }; // Per-source vintage / "as of" — what date the underlying data represents. // For live sources, the answer is "live; observation timestamps in payload". // For archival sources, this is the dataset publication or extent date. const SOURCE_VINTAGES = { geocode: "live (NYC DCP Geosearch v2)", nta_resolve: "NYC NTA 2020 boundaries (DCP, Sept 2022 release)", sandy: "Sandy 2012 inundation extent (NYC OEM survey, dataset published 2013)", sandy_nta: "Sandy 2012 inundation extent (polygon-aggregated)", dep_extreme_2080: "NYC DEP Stormwater Flood Map — Extreme + 2080 SLR (2021 release)", dep_moderate_2050: "NYC DEP Stormwater Flood Map — Moderate + 2050 SLR (2021 release)", dep_moderate_current: "NYC DEP Stormwater Flood Map — Moderate, current SLR (2021 release)", dep_extreme_2080_nta: "NYC DEP Extreme-2080 (2021 release; polygon-aggregated)", dep_moderate_2050_nta: "NYC DEP Moderate-2050 (2021 release; polygon-aggregated)", dep_moderate_current_nta: "NYC DEP Moderate-current (2021 release; polygon-aggregated)", floodnet: "live FloodNet sensor stream (per-event timestamps in payload)", nyc311: "live NYC 311 archive, trailing 5-year window (latest record in payload)", nyc311_nta: "live NYC 311 archive, trailing 3-year window (polygon-aggregated)", microtopo: "USGS 3DEP DEM (NYC LiDAR collect, ~2018) + derived HAND/TWI", microtopo_nta: "USGS 3DEP DEM (NYC ~2018) — polygon-aggregated stats", ida_hwm: "USGS Short-Term Network Event 312 — Hurricane Ida 2021 high-water marks (Sept 1-2 2021 survey)", prithvi_water: "Prithvi-EO 2.0 satellite segmentation, scenes 2021-08-25 (pre) & 2021-09-02 (post Ida)", prithvi_live: "live Sentinel-2 L2A scene from Microsoft Planetary Computer (acquisition timestamp in payload), segmented by the NYC-Pluvial v2 fine-tune of Prithvi-EO 2.0 (test flood IoU 0.5979)", terramind_synthetic: "synthetic prior — TerraMind 1.0 base generated a plausible categorical land-cover map from the LiDAR terrain at this point (deterministic seed, 10 diffusion steps; class fractions cite-able; not a measurement)", tm_lulc: "live empirical observation — TerraMind-NYC LULC LoRA (msradam/TerraMind-NYC-Adapters, fine-tuned on NYC chips on AMD MI300X) over the per-query Sentinel-2/1/DEM chip; 5-class macro land cover with class fractions cite-able", tm_buildings: "live empirical observation — TerraMind-NYC Buildings LoRA (msradam/TerraMind-NYC-Adapters, fine-tuned on NYC chips on AMD MI300X) over the per-query Sentinel-2/1/DEM chip; binary building-footprint mask + connected-component count", gliner_comptroller: "GLiNER typed extraction over the Comptroller PDF (per-paragraph)", gliner_dep_2013: "GLiNER typed extraction over the DEP wastewater plan", gliner_nycha: "GLiNER typed extraction over the NYCHA Lessons Learned PDF", gliner_mta: "GLiNER typed extraction over the MTA Resilience Roadmap", gliner_coned: "GLiNER typed extraction over the Con Edison Climate Resilience plan", noaa_tides: "live NOAA CO-OPS, 6-min cadence (observation time in payload)", nws_alerts: "live NWS Public Alerts API (effective/expires in payload)", nws_obs: "live NWS hourly METAR observation (observation time in payload)", ttm_forecast: "live TTM forecast based on trailing 51 h at the closest NOAA gauge to this address (Battery / Kings Pt / Sandy Hook)", ttm_311_forecast: "live TTM forecast based on trailing 52 weeks of NYC 311 flood complaints within 200 m of this address", floodnet_forecast: "live TTM forecast based on the 512-day daily flood-event series at the nearest FloodNet sensor", ttm_battery: "live NYC fine-tuned TTM forecast based on the trailing 1024 hours (~43 days) of hourly surge residual at the Battery; 96 h horizon", dob_permits: "live NYC DOB Permit Issuance, trailing 18-month window (per-permit issuance dates in payload)", rag_comptroller: "NYC Comptroller report 'Is NYC Ready for Rain?' (2024)", rag_npcc4: "NPCC4 — NYC Climate Assessment 4th edition, Annals NYAS vol. 1539 (2024)", rag_mta: "MTA Climate Resilience Roadmap, October 2025 update", rag_nycha: "NYCHA Flood Resilience: Lessons Learned (post-Sandy)", rag_coned: "Con Edison Climate Change Resilience Plan, NY PSC Case 22-E-0222 (2023)", scope_note: "Riprap planner — geographic scope guard (this query)", live_target: "Riprap planner — live target (this query)", mta_entrance: "MTA Open Data subway-entrance geometry (refreshed monthly) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM", nycha_dev: "NYC Open Data NYCHA Developments (phvi-damg) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM", doe_school: "NYC DOE Locations Points (1992 schools) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM", nyc_hospital: "NYS DOH Health Facility Certification (vn5v-hh5r, NYC counties + fac_desc_short=HOSP) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM", }; const INTENT_PILL_CLASS = { development_check: "dev", live_now: "live", neighborhood: "nbhd", single_address: "addr", }; // --------------------------------------------------------------------------- // MAP // --------------------------------------------------------------------------- let map = null; let mapInit = false; function ensureMap() { if (mapInit) return; mapInit = true; map = new maplibregl.Map({ container: "map", style: { version: 8, // CARTO Voyager — more editorial typography + softer palette than // Positron, no API key required. Retina (@2x) tiles for crisp type. sources: { basemap: { type: "raster", tiles: [ "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png", "https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png", "https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png", "https://d.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png", ], tileSize: 256, attribution: "© OpenStreetMap contributors © CARTO", }, }, layers: [ { id: "bg", type: "background", paint: { "background-color": "#f3f5f8" } }, { id: "basemap", type: "raster", source: "basemap" }, ], }, center: [-74.0, 40.72], zoom: 10, attributionControl: { compact: true }, // Required for map.getCanvas().toDataURL() to work on the report-export // path. Otherwise the WebGL drawing buffer is cleared after each frame // and snapshots come back blank. preserveDrawingBuffer: true, }); map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "top-right"); map.on("load", initMapSources); } function initMapSources() { // Sandy + DEP overlays (used for nbhd / dev_check) map.addSource("sandy", { type: "geojson", data: empty() }); map.addLayer({ id: "sandy-fill", type: "fill", source: "sandy", paint: { "fill-color": "#fc5d52", "fill-opacity": 0.25 } }); map.addLayer({ id: "sandy-line", type: "line", source: "sandy", paint: { "line-color": "#fc5d52", "line-width": 0.5, "line-opacity": 0.7 } }); map.addSource("dep", { type: "geojson", data: empty() }); map.addLayer({ id: "dep-fill", type: "fill", source: "dep", paint: { "fill-color": ["match", ["get", "Flooding_Category"], 1, "#568adf", 2, "#1642DF", 3, "#031553", "#568adf"], "fill-opacity": 0.32 } }); // Prithvi-EO 2.0 live water-segmentation polygons. Cyan to differ // visually from Sandy (red) and DEP (blue) — this is *observed today* // water from the latest cloud-free Sentinel-2 scene, not a modeled // scenario. We outline + fill so even sliver geometries (river edges, // canal banks) show up at street zoom. map.addSource("prithvi_live", { type: "geojson", data: empty() }); map.addLayer({ id: "prithvi-live-fill", type: "fill", source: "prithvi_live", paint: { "fill-color": "#48c6eb", "fill-opacity": 0.45 } }); map.addLayer({ id: "prithvi-live-line", type: "line", source: "prithvi_live", paint: { "line-color": "#1aa3c8", "line-width": 1.2, "line-opacity": 0.85 } }); // TerraMind synthesised LULC polygons — *synthetic-prior* tier // (4th epistemic class). Per-feature fill_color carried from the // server side so the legend stays in one place. Dashed outline so // it visually reads as "synthesized, not observed". map.addSource("terramind_lulc", { type: "geojson", data: empty() }); map.addLayer({ id: "terramind-lulc-fill", type: "fill", source: "terramind_lulc", paint: { "fill-color": ["coalesce", ["get", "fill_color"], "#9ca3af"], "fill-opacity": 0.30, }, }); map.addLayer({ id: "terramind-lulc-line", type: "line", source: "terramind_lulc", paint: { "line-color": ["coalesce", ["get", "fill_color"], "#9ca3af"], "line-width": 1.0, "line-dasharray": [2, 2], "line-opacity": 0.65, }, }); map.on("click", "terramind-lulc-fill", (e) => { const f = e.features[0]; const p = f.properties; new maplibregl.Popup().setLngLat(e.lngLat) .setHTML(`TerraMind synthetic land-cover
` + `Class: ${escapeHtml(p.label || "")} (tentative)
` + `Synthesised from LiDAR DEM, not observed.`) .addTo(map); }); // NTA polygon outline map.addSource("nta", { type: "geojson", data: empty() }); map.addLayer({ id: "nta-line", type: "line", source: "nta", paint: { "line-color": "#0b3b6b", "line-width": 2.4, "line-opacity": 0.9 } }); map.addLayer({ id: "nta-fill", type: "fill", source: "nta", paint: { "fill-color": "#0b3b6b", "fill-opacity": 0.04 } }); // DOB permit pins map.addSource("permits", { type: "geojson", data: empty() }); map.addLayer({ id: "permits-circles", type: "circle", source: "permits", paint: { "circle-radius": ["case", ["get", "any_flood"], 6, 4], "circle-color": [ "case", ["get", "in_sandy"], "#fc5d52", [">=", ["get", "dep_max_class"], 2], "#1642DF", [">", ["get", "dep_max_class"], 0], "#568adf", "#1a8754", ], "circle-stroke-color": "#ffffff", "circle-stroke-width": 1.4, "circle-opacity": 0.95, } }); map.on("click", "permits-circles", (e) => { const f = e.features[0]; const p = f.properties; new maplibregl.Popup() .setLngLat(f.geometry.coordinates) .setHTML( `${escapeHtml(p.address || "(unknown)")}
` + `${p.job_type} · ${p.in_sandy === 'true' ? 'Sandy zone' : 'outside Sandy'}
` + `DEP class: ${p.dep_max_class}`) .addTo(map); }); // Address pin (single_address intent) map.addSource("addr", { type: "geojson", data: empty() }); map.addLayer({ id: "addr-pin", type: "circle", source: "addr", paint: { "circle-radius": 10, "circle-color": "#0b3b6b", "circle-stroke-color": "#fff", "circle-stroke-width": 3 } }); // Search-radius circles (200 m / 600 m / 800 m). Visualizes the // spatial scope each specialist is reading from. Drawn as a thin // line so the underlying point data is readable through them. map.addSource("scope", { type: "geojson", data: empty() }); map.addLayer({ id: "scope-line", type: "line", source: "scope", paint: { "line-color": "#0b3b6b", "line-width": 1.0, "line-opacity": 0.55, "line-dasharray": [3, 3] } }); // NYC 311 flood complaint pins — coloured by descriptor. map.addSource("nyc311_pts", { type: "geojson", data: empty() }); map.addLayer({ id: "nyc311-circles", type: "circle", source: "nyc311_pts", paint: { "circle-radius": 4.5, "circle-color": ["match", ["get", "descriptor"], "Sewer Backup (Use Comments) (SA)", "#fc5d52", "Catch Basin Clogged/Flooding (Use Comments) (SC)", "#f59e0b", "Street Flooding (SJ)", "#1642DF", "Manhole Overflow (Use Comments) (SA1)", "#8b5cf6", "#6b7280", ], "circle-stroke-color": "#ffffff", "circle-stroke-width": 1.0, "circle-opacity": 0.85, }, }); map.on("click", "nyc311-circles", (e) => { const f = e.features[0]; const p = f.properties; new maplibregl.Popup().setLngLat(f.geometry.coordinates) .setHTML(`311 complaint
${escapeHtml(p.descriptor || "")}
` + `${escapeHtml(p.date || "")}
${escapeHtml(p.address || "")}`) .addTo(map); }); // FloodNet sensors — triangles via SDF circle stand-in (cyan, // larger if the sensor has triggered events). map.addSource("floodnet_pts", { type: "geojson", data: empty() }); map.addLayer({ id: "floodnet-circles", type: "circle", source: "floodnet_pts", paint: { "circle-radius": 7, "circle-color": "#48c6eb", "circle-stroke-color": "#1aa3c8", "circle-stroke-width": 2.0, "circle-opacity": 0.95, }, }); map.on("click", "floodnet-circles", (e) => { const f = e.features[0]; const p = f.properties; new maplibregl.Popup().setLngLat(f.geometry.coordinates) .setHTML(`FloodNet sensor
${escapeHtml(p.name || p.deployment_id || "")}`) .addTo(map); }); // USGS Hurricane Ida 2021 high-water marks — hot orange, sized by height. map.addSource("ida_hwm_pts", { type: "geojson", data: empty() }); map.addLayer({ id: "ida-hwm-circles", type: "circle", source: "ida_hwm_pts", paint: { "circle-radius": ["interpolate", ["linear"], ["coalesce", ["get", "height_above_gnd_ft"], 0], 0, 4, 3, 7, 6, 11], "circle-color": "#ea580c", "circle-stroke-color": "#7c2d12", "circle-stroke-width": 1.4, "circle-opacity": 0.92, }, }); map.on("click", "ida-hwm-circles", (e) => { const f = e.features[0]; const p = f.properties; new maplibregl.Popup().setLngLat(f.geometry.coordinates) .setHTML(`USGS Ida 2021 high-water mark
` + `${escapeHtml(p.site || "(unnamed)")}
` + `Elevation: ${p.elev_ft ?? "?"} ft
` + `Height above ground: ${p.height_above_gnd_ft ?? "?"} ft`) .addTo(map); }); // NOAA tide gauge marker — shows which of the 3 gauges is active. map.addSource("noaa_gauge", { type: "geojson", data: empty() }); map.addLayer({ id: "noaa-gauge-marker", type: "circle", source: "noaa_gauge", paint: { "circle-radius": 9, "circle-color": "#0ea5e9", "circle-stroke-color": "#fff", "circle-stroke-width": 2.5, }, }); map.on("click", "noaa-gauge-marker", (e) => { const f = e.features[0]; const p = f.properties; new maplibregl.Popup().setLngLat(f.geometry.coordinates) .setHTML(`NOAA tide gauge
${escapeHtml(p.name || "")}
` + `Observed water level: ${p.observed_ft ?? "?"} ft MLLW
` + `Residual (≈ surge): ${p.residual_ft ?? "?"} ft`) .addTo(map); }); } // ~3 m/° latitude × cos(lat) for longitude. Build a circle polygon // approximating a fixed-radius (meters) buffer around (lat, lon). function metersBuffer(lat, lon, meters, steps = 64) { const dLat = meters / 111_000.0; const dLon = meters / (111_000.0 * Math.cos(lat * Math.PI / 180)); const ring = []; for (let i = 0; i <= steps; i++) { const a = (i / steps) * 2 * Math.PI; ring.push([lon + dLon * Math.cos(a), lat + dLat * Math.sin(a)]); } return { type: "Polygon", coordinates: [ring] }; } function empty() { return { type: "FeatureCollection", features: [] }; } function clearMap() { if (!map || !map.getSource) return; for (const id of ["sandy", "dep", "nta", "permits", "addr", "prithvi_live", "terramind_lulc", "scope", "nyc311_pts", "floodnet_pts", "ida_hwm_pts", "noaa_gauge"]) { const s = map.getSource(id); if (s) s.setData(empty()); } } async function fillMapForFinal(d) { if (!map || !map.loaded()) { map.once("load", () => fillMapForFinal(d)); return; } clearMap(); const intent = d.intent; if (intent === "single_address") return fillMapAddress(d); if (intent === "neighborhood") return fillMapNeighborhood(d); if (intent === "development_check") return fillMapDevelopment(d); if (intent === "live_now") return fillMapLive(d); } async function fillMapAddress(d) { const geo = d.geocode; if (!geo || !geo.lat) return; map.flyTo({ center: [geo.lon, geo.lat], zoom: 15.5, duration: 700 }); map.getSource("addr").setData({ type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [geo.lon, geo.lat] }, properties: {} }] }); // Fetch Sandy + DEP layers clipped to address try { const r = await fetch(`/api/layers/sandy?lat=${geo.lat}&lon=${geo.lon}&r=1500`); map.getSource("sandy").setData(await r.json()); } catch {} try { const r = await fetch(`/api/layers/dep_extreme_2080?lat=${geo.lat}&lon=${geo.lon}&r=1500`); map.getSource("dep").setData(await r.json()); } catch {} // Prithvi-EO live water mask comes inlined in the SSE final event, // not via a separate /api/layers fetch — it's per-query, not corpus. const live = d.prithvi_live; if (live && live.ok && live.polygons_geojson && map.getSource("prithvi_live")) { map.getSource("prithvi_live").setData(live.polygons_geojson); } // TerraMind synthesised LULC polygons — same per-query pattern. const tm = d.terramind; if (tm && tm.ok && tm.polygons_geojson && map.getSource("terramind_lulc")) { map.getSource("terramind_lulc").setData(tm.polygons_geojson); } // ---- search-radius scope rings (200 m / 600 m / 800 m) ---- // Three rings matching the buffers each specialist actually reads: // 200 m for 311, 600 m for FloodNet sensors, 800 m for Ida HWMs. if (map.getSource("scope")) { map.getSource("scope").setData({ type: "FeatureCollection", features: [200, 600, 800].map(r => ({ type: "Feature", geometry: metersBuffer(geo.lat, geo.lon, r), properties: { radius_m: r }, })), }); } // ---- NYC 311 flood complaint pins ---- const c311 = d.nyc311 || {}; const c311Pts = c311.points || []; if (map.getSource("nyc311_pts")) { map.getSource("nyc311_pts").setData({ type: "FeatureCollection", features: c311Pts.filter(p => p.lat && p.lon).map(p => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lon, p.lat] }, properties: { descriptor: p.descriptor || "", date: p.date || "", address: p.address || "", }, })), }); } // ---- FloodNet sensors ---- const fn = d.floodnet || {}; const fnSensors = fn.sensors || []; if (map.getSource("floodnet_pts")) { map.getSource("floodnet_pts").setData({ type: "FeatureCollection", features: fnSensors.filter(s => s.lat && s.lon).map(s => ({ type: "Feature", geometry: { type: "Point", coordinates: [s.lon, s.lat] }, properties: { name: s.name || s.deployment_id || "", deployment_id: s.deployment_id || "", }, })), }); } // ---- USGS Ida 2021 HWMs ---- const hwm = d.ida_hwm || {}; const hwmPts = hwm.points || []; if (map.getSource("ida_hwm_pts")) { map.getSource("ida_hwm_pts").setData({ type: "FeatureCollection", features: hwmPts.filter(p => p.lat && p.lon).map(p => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lon, p.lat] }, properties: { site: p.site || "", elev_ft: p.elev_ft, height_above_gnd_ft: p.height_above_gnd_ft, }, })), }); } // ---- NOAA tide gauge marker ---- const tides = d.noaa_tides || {}; if (tides.station_id && tides.station_lat && tides.station_lon && map.getSource("noaa_gauge")) { map.getSource("noaa_gauge").setData({ type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [tides.station_lon, tides.station_lat] }, properties: { name: tides.station_name || tides.station_id, observed_ft: tides.observed_ft_mllw, residual_ft: tides.residual_ft, }, }], }); } } async function fillMapNeighborhood(d) { const t = d.target; if (!t || !t.bbox || !t.nta_code) return; const [minx, miny, maxx, maxy] = t.bbox; map.fitBounds([[minx, miny], [maxx, maxy]], { padding: 32, duration: 700 }); const [r1, r2, r3] = await Promise.all([ fetch(`/api/layers/nta?code=${t.nta_code}`).then(r => r.json()), fetch(`/api/layers/sandy_clipped?code=${t.nta_code}`).then(r => r.json()).catch(() => empty()), fetch(`/api/layers/dep_clipped?code=${t.nta_code}&scenario=dep_extreme_2080`).then(r => r.json()).catch(() => empty()), ]); map.getSource("nta").setData(r1); map.getSource("sandy").setData(r2); map.getSource("dep").setData(r3); // Prithvi-EO live water mask (NTA centroid) — same per-query GeoJSON // as the single_address path; clipped visually to the NTA polygon by // the basemap zoom. const live = d.prithvi_live; if (live && live.ok && live.polygons_geojson && map.getSource("prithvi_live")) { map.getSource("prithvi_live").setData(live.polygons_geojson); } // TerraMind synthesised LULC at NTA centroid. const tm = d.terramind; if (tm && tm.ok && tm.polygons_geojson && map.getSource("terramind_lulc")) { map.getSource("terramind_lulc").setData(tm.polygons_geojson); } } async function fillMapDevelopment(d) { await fillMapNeighborhood(d); // same NTA + Sandy + DEP overlays const pins = ((d.dob_summary || {}).all_pins) || []; const fc = { type: "FeatureCollection", features: pins.filter(p => p.lat && p.lon).map(p => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lon, p.lat] }, properties: { address: p.address, job_type: p.job_type, in_sandy: !!p.in_sandy, any_flood: !!p.any_flood, dep_max_class: p.dep_max_class || 0, }, })), }; map.getSource("permits").setData(fc); } function fillMapLive(d) { // NYC overview with the 3 NOAA gauges map.flyTo({ center: [-74.0, 40.7], zoom: 10, duration: 700 }); } // Fire as each FSM step completes, so the map updates progressively // instead of waiting for the `final` event. Each branch is idempotent — // it's safe if `final` later overwrites with the same data. async function incrementallyFillMap(step) { if (!map || !map.loaded()) { map.once("load", () => incrementallyFillMap(step)); return; } const r = step.result || {}; // Address mode — geocode just resolved if (step.step === "geocode" && r.lat != null && r.lon != null) { map.flyTo({ center: [r.lon, r.lat], zoom: 15.5, duration: 700 }); map.getSource("addr").setData({ type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [r.lon, r.lat] }, properties: {} }] }); Promise.all([ fetch(`/api/layers/sandy?lat=${r.lat}&lon=${r.lon}&r=1500`).then(x => x.json()).catch(() => empty()), fetch(`/api/layers/dep_extreme_2080?lat=${r.lat}&lon=${r.lon}&r=1500`).then(x => x.json()).catch(() => empty()), ]).then(([s, d]) => { map.getSource("sandy").setData(s); map.getSource("dep").setData(d); }); return; } // Neighborhood / dev_check — NTA polygon resolved if (step.step === "nta_resolve" && r.nta_code && r.bbox) { const [minx, miny, maxx, maxy] = r.bbox; map.fitBounds([[minx, miny], [maxx, maxy]], { padding: 32, duration: 700 }); Promise.all([ fetch(`/api/layers/nta?code=${r.nta_code}`).then(x => x.json()).catch(() => empty()), fetch(`/api/layers/sandy_clipped?code=${r.nta_code}`).then(x => x.json()).catch(() => empty()), fetch(`/api/layers/dep_clipped?code=${r.nta_code}&scenario=dep_extreme_2080`).then(x => x.json()).catch(() => empty()), ]).then(([n, s, d]) => { map.getSource("nta").setData(n); map.getSource("sandy").setData(s); map.getSource("dep").setData(d); }); return; } // Dev_check — DOB permits arrived; pin them now if (step.step === "dob_permits_nta" && Array.isArray(r.all_pins)) { const fc = { type: "FeatureCollection", features: r.all_pins.filter(p => p.lat && p.lon).map(p => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lon, p.lat] }, properties: { address: p.address, job_type: p.job_type, in_sandy: !!p.in_sandy, any_flood: !!p.any_flood, dep_max_class: p.dep_max_class || 0, }, })) }; map.getSource("permits").setData(fc); return; } } // --------------------------------------------------------------------------- // REPORT (paragraph) RENDERING // --------------------------------------------------------------------------- function escapeHtml(s) { return String(s ?? "").replace(/&/g, "&").replace(//g, ">"); } let CITE_INDEX = {}; // Resolve a doc_id to its source-label family. Register specialists emit // per-asset doc_ids like `mta_entrance_54` / `nycha_dev_004` — for those // we strip the trailing `_` and look up the family key. const _FAMILY_PREFIXES = ["mta_entrance", "nycha_dev", "doe_school", "nyc_hospital"]; function _docIdFamily(norm) { for (const fam of _FAMILY_PREFIXES) { if (norm.startsWith(fam + "_")) return fam; } return null; } function _resolveSourceLabel(norm) { if (SOURCE_LABELS[norm]) return SOURCE_LABELS[norm]; const fam = _docIdFamily(norm); return fam ? SOURCE_LABELS[fam] : norm; } function rewriteCitations(html) { return html.replace(/\[([a-z0-9_]+)\]/gi, (_, id) => { const norm = id.toLowerCase(); if (CITE_INDEX[norm] == null) CITE_INDEX[norm] = Object.keys(CITE_INDEX).length + 1; const n = CITE_INDEX[norm]; return `${n}`; }); } // Sources footer is a Lit web component () — driven // by the citeIndex signal in /static/components/signals.js. We feed // it the labels/urls/vintages once at boot and update the signal as // the briefing markdown is rendered. async function renderSources() { const el = document.getElementById("sourcesFooter"); if (!el) return; // Module is loaded async; wait for define() then push fresh data. await customElements.whenDefined("r-sources-footer"); el.labels = SOURCE_LABELS; el.urls = SOURCE_URLS; el.vintages = SOURCE_VINTAGES; // Push the citation index into the shared signal — the component // re-renders reactively. const { citeIndex } = await import("/static/components/signals.js"); citeIndex.set({ ...CITE_INDEX }); } function renderMarkdown(text) { // Block recognizer: // `**Header.**` (own line) →

// lines starting `- ` or `* ` → bullet items collected into
    // anything else →

    // Inline `**foo**` → const lines = text.split("\n"); const out = []; let para = []; let bullets = []; const flushPara = () => { if (!para.length) return; const safe = escapeHtml(para.join(" ").trim()).replace(/\*\*([^*]+)\*\*/g, "$1"); if (safe) out.push(`

    ${safe}

    `); para = []; }; const flushBullets = () => { if (!bullets.length) return; const items = bullets.map(b => { const safe = escapeHtml(b.trim()).replace(/\*\*([^*]+)\*\*/g, "$1"); return `
  • ${safe}
  • `; }).join(""); out.push(`
      ${items}
    `); bullets = []; }; // Granite sometimes runs all bullets onto one line separated by " - "; // pre-split those so each becomes its own bullet. const expanded = []; for (const line of lines) { if (line.trim().startsWith("- ") && line.includes(" - ", 2)) { // split into bullets const parts = line.split(/(?:^|(?<=\.\s))\s*-\s+/g).filter(p => p.trim()); for (const p of parts) expanded.push("- " + p.trim()); } else { expanded.push(line); } } for (const line of expanded) { const m = line.match(/^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/); if (m) { flushPara(); flushBullets(); out.push(`

    ${escapeHtml(m[1])}

    `); continue; } if (/^\s*[-*]\s+/.test(line)) { flushPara(); bullets.push(line.replace(/^\s*[-*]\s+/, "")); } else { flushBullets(); para.push(line); } } flushPara(); flushBullets(); return out.join(""); } // Briefing is now the Lit web component. It owns markdown // rendering, citation chip binding, and pushing CITE_INDEX into the // shared signal — agent.js just feeds it `.text` + `.sourceLabels`. async function setBriefingText(text) { const el = document.getElementById("paragraph"); if (!el) return; await customElements.whenDefined("r-briefing"); el.sourceLabels = SOURCE_LABELS; el.text = text || ""; } function renderParagraph(text) { setBriefingText(text); } // --------------------------------------------------------------------------- // FACTS PANEL — intent-specific quick-look stats below the map // --------------------------------------------------------------------------- function renderFacts(d) { const intent = d.intent; const panel = $("#factsPanel"); const body = $("#factsBody"); panel.style.display = ""; if (intent === "neighborhood") renderNbhdFacts(d, body); else if (intent === "development_check") renderDevFacts(d, body); else if (intent === "live_now") renderLiveFacts(d, body); else if (intent === "single_address") renderAddressFacts(d, body); } function renderNbhdFacts(d, body) { $("#factsTitle").textContent = `Findings — ${d.target?.nta_name || ""}`; const s = d.sandy_nta || {}; const dep = d.dep_nta || {}; const m = d.microtopo_nta || {}; const c = d.nyc311_nta || {}; const sandyPct = s.fraction != null ? (s.fraction * 100).toFixed(1) + "%" : "—"; const dep80 = (dep.dep_extreme_2080 || {}).fraction_any; const dep50 = (dep.dep_moderate_2050 || {}).fraction_any; body.innerHTML = `
    ${sandyPct}
    of the neighborhood is inside the 2012 Sandy Inundation Zone
    DEP Extreme 2080
    ${dep80!=null ? (dep80*100).toFixed(1)+"%" : "—"}
    DEP Moderate 2050
    ${dep50!=null ? (dep50*100).toFixed(1)+"%" : "—"}
    311 (3 yr)
    ${c.n ?? "—"} flood complaints
    HAND median
    ${m.hand_median_m != null ? m.hand_median_m+" m" : "—"}
    HAND < 1 m fraction
    ${m.frac_hand_lt1 != null ? (m.frac_hand_lt1*100).toFixed(0)+"%" : "—"}
    TWI median
    ${m.twi_median ?? "—"}
    `; } function renderDevFacts(d, body) { $("#factsTitle").textContent = `Active construction — ${d.target?.nta_name || ""}`; const ds = d.dob_summary || {}; body.innerHTML = `
    ${ds.n_in_sandy ?? 0} / ${ds.n_total ?? 0}
    active projects inside the Sandy zone
    Total active
    ${ds.n_total ?? 0}
    In any DEP scenario
    ${ds.n_in_dep_any ?? 0}
    In severe DEP (≥1 ft)
    ${ds.n_in_dep_severe ?? 0}
    By job type
    ${Object.entries(ds.by_job_type || {}).map(([k,v]) => `${v} ${k}`).join(", ")}
    `; } function renderLiveFacts(d, body) { $("#factsTitle").textContent = `Live conditions — ${d.place || "NYC"}`; const t = d.noaa_tides || {}; const a = d.nws_alerts || {}; const o = d.nws_obs || {}; const ttm = d.ttm_forecast || {}; const r = t.residual_ft; body.innerHTML = `
    ${a.n_active ?? 0} alerts
    active flood-relevant NWS alerts at this point
    Tide gauge
    ${t.station_name || "—"}
    Observed
    ${t.observed_ft_mllw != null ? t.observed_ft_mllw+" ft MLLW" : "—"}
    Residual
    ${r != null ? (r >= 0 ? "+" : "")+r+" ft" : "—"}
    Nearest ASOS
    ${o.station_id || "—"}
    Precip 1h
    ${o.precip_last_hour_mm != null ? o.precip_last_hour_mm+" mm" : "—"}
    TTM peak (next 9.6h)
    ${ttm.forecast_peak_ft != null ? ttm.forecast_peak_ft+" ft" : "—"}
    `; } function renderAddressFacts(d, body) { $("#factsTitle").textContent = "Findings"; const geo = d.geocode || {}; const dep = d.dep || {}; const e80 = (dep.dep_extreme_2080 || {}); const m = d.microtopo || {}; body.innerHTML = `
    ${geo.address || "—"}
    Sandy zone
    ${d.sandy ? "INSIDE" : "outside"}
    DEP Extreme 2080
    ${e80.depth_label || "—"}
    HAND
    ${m.hand_m != null ? m.hand_m+" m" : "—"}
    TWI
    ${m.twi ?? "—"}
    Elev pct (200m)
    ${m.rel_elev_pct_200m ?? "—"}
    311 (5y, 200m)
    ${(d.nyc311 || {}).n ?? "—"}
    `; } // --------------------------------------------------------------------------- // TRACE PANEL // --------------------------------------------------------------------------- // Trace list is a Lit web component (); pushTraceStep delegates // once the component is registered. STEP_LABELS is set on the element // at boot. async function pushTraceStep(step) { const el = document.getElementById("steps"); if (!el) return; await customElements.whenDefined("r-trace"); if (!el.stepLabels || !Object.keys(el.stepLabels).length) { el.stepLabels = STEP_LABELS; } el.pushStep(step); } async function clearTrace() { const el = document.getElementById("steps"); if (el) { await customElements.whenDefined("r-trace"); el.clear(); } $("#traceMeta").textContent = ""; } // -------------------------------------------------------------------------- // Loading-state and chrome helpers // -------------------------------------------------------------------------- function setMapLoading(text) { const el = $("#mapLoading"); if (!el) return; if (text) { el.style.display = ""; $("#mapLoadingText").textContent = text; } else { el.style.display = "none"; } } function setLegend(intent) { const el = $("#mapLegend"); if (!el) return; // Reusable legend rows shared across intents. const empirical = `
    Sandy 2012 extent
    DEP Extreme-2080
    `; const points = `
    311 — sewer backup
    311 — catch basin
    311 — street flooding
    FloodNet sensor
    Ida 2021 high-water mark
    NOAA tide gauge
    `; // Synthetic-prior tier — distinct visual idiom (dashed) so users // immediately read it as "generated, not observed". const synthetic = `
    Synthetic priors (not observed)
    Prithvi-EO 2.0 — live water mask
    TerraMind — synthetic LULC (DEM→ESRI Land Cover, dashed = generated)
    `; if (intent === "development_check") { el.innerHTML = `
    Active permits
    Inside Sandy zone
    DEP deep band (≥1 ft)
    DEP nuisance band
    No flood layer
    ${empirical}
    ${synthetic}`; el.style.display = ""; } else if (intent === "neighborhood") { el.innerHTML = `${empirical}
    NTA boundary
    ${synthetic}`; el.style.display = ""; } else if (intent === "single_address") { el.innerHTML = `
    Address
    ${empirical}${points}${synthetic}`; el.style.display = ""; } else { el.style.display = "none"; } } // Mirrors app/score.py.composite() — see ARCHITECTURE.md / METHODOLOGY.md. // Used only for the single_address intent badge; neighborhood and // development_check have their own headline stats in the facts panel. const REG_W = { fema_1pct: 1.0, fema_02pct: 0.5, dep_moderate_2050: 0.75, dep_extreme_2080: 0.50, dep_tidal_2050: 0.75 }; const HYD_W = { hand_band: 1.0, twi_quartile: 0.5, elev_pct_200m_inv: 0.5, elev_pct_750m_inv: 0.5, basin_relief_band: 0.25 }; const EMP_W = { sandy: 1.0, ida_hwm_within_100m: 1.0, ida_hwm_within_800m: 0.5, prithvi_polygon: 0.75, complaints_band: 0.75, floodnet_trigger: 0.75 }; const handBand = h => h == null ? 0 : (h < 1 ? 1 : h < 3 ? 0.66 : h < 10 ? 0.33 : 0); const pctInvBand = p => p == null ? 0 : (p < 10 ? 1 : p < 25 ? 0.66 : p < 50 ? 0.33 : 0); const twiBand = t => t == null ? 0 : (t >= 12 ? 1 : t >= 10 ? 0.66 : t >= 8 ? 0.33 : 0); const reliefBand = r => r == null ? 0 : (r >= 8 ? 1 : r >= 4 ? 0.66 : r >= 2 ? 0.33 : 0); const complBand = n => !n ? 0 : (n >= 10 ? 1 : n >= 3 ? 0.66 : 0.33); const sumW = w => Object.values(w).reduce((a, b) => a + b, 0); function computeComposite(ev) { const dep = ev.dep || {}, mt = ev.microtopo || {}, ida = ev.ida_hwm || {}, pw = ev.prithvi_water || {}; const s = { fema_1pct: false, fema_02pct: false, dep_moderate_2050: (dep.dep_moderate_2050?.depth_class || 0) > 0, dep_extreme_2080: (dep.dep_extreme_2080?.depth_class || 0) > 0, dep_tidal_2050: false, hand_m: mt.hand_m, twi: mt.twi, rel_elev_pct_200m: mt.rel_elev_pct_200m, rel_elev_pct_750m: mt.rel_elev_pct_750m, basin_relief_m: mt.basin_relief_m, sandy: !!ev.sandy, ida_hwm_within_100m: (ida.nearest_dist_m != null && ida.nearest_dist_m < 100), ida_hwm_within_800m: (ida.n_within_radius || 0) > 0, prithvi_polygon: !!pw.inside_water_polygon, complaints_count: ev.nyc311?.n || 0, floodnet_trigger: (ev.floodnet?.n_flood_events_3y || 0) > 0, }; let regRaw = 0; for (const [k, w] of Object.entries(REG_W)) regRaw += s[k] ? w : 0; const reg = regRaw / sumW(REG_W); const hb = { hand_band: handBand(s.hand_m), twi_quartile: twiBand(s.twi), elev_pct_200m_inv: pctInvBand(s.rel_elev_pct_200m), elev_pct_750m_inv: pctInvBand(s.rel_elev_pct_750m), basin_relief_band: reliefBand(s.basin_relief_m) }; let hydRaw = 0; for (const [k, w] of Object.entries(HYD_W)) hydRaw += w * hb[k]; const hyd = hydRaw / sumW(HYD_W); const ev2 = { sandy: s.sandy ? 1 : 0, ida_hwm_within_100m: s.ida_hwm_within_100m ? 1 : 0, ida_hwm_within_800m: s.ida_hwm_within_800m ? 1 : 0, prithvi_polygon: s.prithvi_polygon ? 1 : 0, complaints_band: complBand(s.complaints_count), floodnet_trigger: s.floodnet_trigger ? 1 : 0 }; let empRaw = 0; for (const [k, w] of Object.entries(EMP_W)) empRaw += w * ev2[k]; const emp = empRaw / sumW(EMP_W); const composite = reg + hyd + emp; let tier = 0; if (composite >= 1.50) tier = 1; else if (composite >= 1.00) tier = 2; else if (composite >= 0.50) tier = 3; else if (composite >= 0.01) tier = 4; const floorApplied = !!(s.sandy || s.ida_hwm_within_100m); if (floorApplied && (tier === 0 || tier > 2)) tier = 2; return { tier, composite, floorApplied, sub: { regulatory: reg, hydrological: hyd, empirical: emp } }; } function tierMeta(tier) { if (tier === 1) return { tier, label: "High exposure", help: "Multiple sub-indices saturated. Not a damage probability." }; if (tier === 2) return { tier, label: "Elevated exposure", help: "At least one sub-index near saturation. Not a damage probability." }; if (tier === 3) return { tier, label: "Moderate exposure", help: "Partial signals across categories. Not a damage probability." }; if (tier === 4) return { tier, label: "Limited exposure", help: "A single contextual signal." }; return { tier: 0, label: "No flagged exposure", help: "No positive flood signal across the assessed sources." }; } function renderBriefHead(d) { const intent = d.intent; const place = (d.target && d.target.nta_name) || (d.geocode && d.geocode.address) || d.place || "—"; const meta = []; const eyebrowMap = { single_address: "Flood-exposure briefing — address", neighborhood: "Flood-exposure briefing — neighborhood", development_check: "Active development × flood exposure", live_now: "Current conditions — NYC", }; $("#briefEyebrow").textContent = eyebrowMap[intent] || "Briefing"; $("#briefTitle").innerHTML = escapeHtml(place); // For single_address intent, append the tier badge inline with the title // — same idiom as the legacy /single page. if (intent === "single_address") { const c = computeComposite(d); const m = tierMeta(c.tier); const titleEl = $("#briefTitle"); const floor = c.floorApplied ? ' empirical floor' : ""; titleEl.innerHTML += ` Tier ${m.tier} · ${escapeHtml(m.label)}${floor} `; } // Mellea compliance badge — present iff strict mode ran and produced // metadata. Color reflects pass ratio: green for full, amber partial, // red none. if (d.mellea) { const m = d.mellea; const passed = (m.requirements_passed || []).length; const total = m.requirements_total || 0; const cls = passed === total ? "full" : passed > 0 ? "partial" : "none"; const tip = `Mellea (IBM Research) ran ${m.n_attempts} attempt${m.n_attempts === 1 ? "" : "s"}` + ` (${m.rerolls} reroll${m.rerolls === 1 ? "" : "s"}). ` + `Requirements passed: ${(m.requirements_passed || []).join(", ") || "none"}. ` + (m.requirements_failed?.length ? `Failed: ${m.requirements_failed.join(", ")}.` : ""); $("#briefTitle").innerHTML += ` ` + `Mellea ${passed}/${total}` + (m.rerolls > 0 ? ` · ${m.rerolls} reroll${m.rerolls === 1 ? "" : "s"}` : "") + ``; } if (intent === "single_address" && d.geocode) { if (d.geocode.borough) meta.push(`borough ${escapeHtml(d.geocode.borough)}`); if (d.geocode.bbl) meta.push(`bbl ${escapeHtml(d.geocode.bbl)}`); } else if (d.target && d.target.borough) { meta.push(`borough ${escapeHtml(d.target.borough)}`); if (d.target.nta_code) meta.push(`nta ${escapeHtml(d.target.nta_code)}`); } if (d.total_s != null) meta.push(`runtime ${d.total_s}s`); meta.push(`assessed ${new Date().toISOString().slice(0,16).replace("T"," ")}`); $("#briefMeta").innerHTML = meta.join('·'); } // --------------------------------------------------------------------------- // PLANNER ROW // --------------------------------------------------------------------------- function renderPlan(p) { const pillCls = INTENT_PILL_CLASS[p.intent] || ""; $("#plannerRow").innerHTML = `
    Planner
    ${escapeHtml(p.intent)}
    Targets
    ${(p.targets || []).map(t => escapeHtml(t.type) + ":" + escapeHtml(t.text)).join(", ") || "(none)"}
    Specialists
    ${(p.specialists || []).join(", ")}
    "${escapeHtml(p.rationale || "")}"
    `; } // --------------------------------------------------------------------------- // SSE driver // --------------------------------------------------------------------------- let currentEs = null; // Buffers for the report-export feature — capture the full plan, trace, // and final result during streaming so the report page can render the // complete evidence package without re-running the agent. let LAST_RESULT = null; let LAST_TRACE = []; let LAST_PLAN = null; let LAST_PLAN_OBJ = null; let TRACE_BUF = []; function ask(q) { ensureMap(); clearTrace(); clearMap(); $("#plannerRow").innerHTML = ""; setBriefingText(""); $("#paragraph").classList.remove("streaming"); const banner = $("#melleaBanner"); if (banner) { banner.style.display = "none"; banner.innerHTML = ""; } $("#reportPanel").style.display = "none"; $("#factsPanel").style.display = "none"; $("#reportSkel").style.display = ""; $("#traceSkel").style.display = ""; $("#mapLegend").style.display = "none"; setMapLoading("Granite is planning the query…"); $("#goBtn").disabled = true; $("#traceMeta").textContent = "…"; if (currentEs) currentEs.close(); const es = new EventSource("/api/agent/stream?q=" + encodeURIComponent(q)); currentEs = es; const t0 = Date.now(); let streamBuf = ""; let streamTimer = null; let planStreamBuf = ""; let planStreamTimer = null; const ensurePlannerStream = () => { let el = $("#plannerRow .planner-streaming"); if (!el) { $("#plannerRow").innerHTML = `
    `; el = $("#plannerRow .planner-streaming"); } return el; }; const repaintPlanner = () => { const el = $("#plannerRow .planner-streaming"); if (el) el.textContent = planStreamBuf; }; const schedulePlannerRepaint = () => { if (planStreamTimer) return; planStreamTimer = setTimeout(() => { planStreamTimer = null; repaintPlanner(); }, 60); }; // Re-render the partial markdown on every token, but at most every 80 ms // so the browser isn't murdered by a token-stream that arrives in bursts. // Build the Sources footer alongside so it grows as new doc_ids appear. // Briefing component owns citation indexing + chip binding via shared // signals; we just feed it the latest text. Sources footer reacts to // the citeIndex signal that updates each render. const repaint = () => { setBriefingText(streamBuf); renderSources(); }; const scheduleRepaint = () => { if (streamTimer) return; streamTimer = setTimeout(() => { streamTimer = null; repaint(); }, 80); }; es.addEventListener("plan_token", (e) => { ensurePlannerStream(); const d = JSON.parse(e.data); planStreamBuf += d.delta || ""; schedulePlannerRepaint(); }); es.addEventListener("plan", (e) => { if (planStreamTimer) { clearTimeout(planStreamTimer); planStreamTimer = null; } const planObj = JSON.parse(e.data); LAST_PLAN_OBJ = planObj; renderPlan(planObj); setLegend(planObj.intent); setMapLoading(planObj.intent === "live_now" ? null : "Resolving location…"); $("#traceSkel").style.display = "none"; TRACE_BUF = []; $("#reportBtn").classList.remove("ready"); }); es.addEventListener("step", (e) => { const step = JSON.parse(e.data); TRACE_BUF.push(step); incrementallyFillMap(step); if (step.step === "geocode" || step.step === "nta_resolve") setMapLoading(null); }); es.addEventListener("step", (e) => { pushTraceStep(JSON.parse(e.data)); }); // Stones envelope — `stone_start` and `stone_done` events bracket // the contiguous step events of each Stone group. The current // `` Svelte build doesn't yet render parent/child rows; // we accumulate Stone markers in TRACE_BUF for the auditable report, // and surface a lightweight badge on the trace component so users // can see Cornerstone / Keystone / Touchstone / Lodestone / Capstone // lighting up sequentially. The full collapsible parent-row UI // lands once the trace component is rebuilt against this event // vocabulary. es.addEventListener("stone_start", (e) => { const stone = JSON.parse(e.data); TRACE_BUF.push({ _stone: "start", ...stone }); const trace = $("#trace"); if (trace && typeof trace.markStoneStart === "function") { trace.markStoneStart(stone); } }); es.addEventListener("stone_done", (e) => { const stone = JSON.parse(e.data); TRACE_BUF.push({ _stone: "done", ...stone }); const trace = $("#trace"); if (trace && typeof trace.markStoneDone === "function") { trace.markStoneDone(stone); } }); let currentAttempt = 0; es.addEventListener("token", (e) => { const d = JSON.parse(e.data); if (!streamBuf || (d.attempt != null && d.attempt !== currentAttempt)) { // First token of a (possibly new) attempt → reveal panel, reset // buffer if Mellea moved to a reroll. if (d.attempt != null && d.attempt !== currentAttempt) { currentAttempt = d.attempt; streamBuf = ""; } $("#reportSkel").style.display = "none"; $("#reportPanel").style.display = ""; $("#paragraph").classList.add("streaming"); } streamBuf += d.delta || ""; scheduleRepaint(); }); // Mellea per-attempt outcome — render a small banner above the briefing // when a reroll is about to start so the user knows the model is // self-correcting (and what failed). es.addEventListener("mellea_attempt", (e) => { const d = JSON.parse(e.data); const banner = $("#melleaBanner"); if (!banner) return; if (d.failed && d.failed.length) { banner.className = "mellea-banner reroll"; banner.innerHTML = `↻ Mellea reroll — attempt ${(d.attempt|0)+1} failed: ${d.failed.join(", ")}. Re-drafting…`; banner.style.display = ""; } else { banner.className = "mellea-banner pass"; banner.innerHTML = `✓ Mellea — all 4 grounding requirements satisfied`; banner.style.display = ""; } }); es.addEventListener("final", (e) => { const d = JSON.parse(e.data); const dt = ((Date.now() - t0) / 1000).toFixed(1); $("#traceMeta").textContent = `${dt}s`; setMapLoading(null); $("#reportSkel").style.display = "none"; $("#paragraph").classList.remove("streaming"); if (d.paragraph) { $("#reportPanel").style.display = ""; streamBuf = d.paragraph; if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; } repaint(); renderBriefHead(d); } renderFacts(d); fillMapForFinal(d); // Stash everything needed for the auditable-report page. LAST_RESULT = { query: q, finishedAt: new Date().toISOString(), wallSeconds: Number(dt), result: d }; LAST_TRACE = TRACE_BUF.slice(); LAST_PLAN = LAST_PLAN_OBJ; $("#reportBtn").classList.add("ready"); }); es.addEventListener("error", () => {}); es.addEventListener("done", () => { es.close(); $("#goBtn").disabled = false; }); } // --------------------------------------------------------------------------- // wire // --------------------------------------------------------------------------- // Bind form/sample handlers FIRST so a throw in ensureMap() (e.g. a // WebGL init failure) can't strand the user with a dead "Ask" button. $("#agentForm").addEventListener("submit", (e) => { e.preventDefault(); const q = $("#q").value.trim(); if (q) ask(q); }); document.querySelectorAll(".sample-btn").forEach(b => { b.addEventListener("click", () => { $("#q").value = b.dataset.q; ask(b.dataset.q); }); }); try { ensureMap(); } catch (e) { console.error("ensureMap failed:", e); } // Backend hardware pill: fetches /api/backend, renders " · " // and a state color (green=primary up, amber=fallback active, red=down). // Refreshes every 60s so a flipped droplet shows up without a page reload. async function refreshBackendPill() { const pill = document.getElementById("backendPill"); const text = document.getElementById("backendPillText"); if (!pill || !text) return; try { const r = await fetch("/api/backend", { cache: "no-store" }); if (!r.ok) throw new Error("status " + r.status); const info = await r.json(); const onFallback = info.reachable === false && !!info.fallback_engine; const engine = onFallback ? info.fallback_engine : info.engine; const hw = onFallback ? "fallback" : info.hardware; text.textContent = `${hw} · Granite 4.1 / ${engine}`; pill.dataset.state = info.reachable ? "ok" : onFallback ? "fallback" : "down"; const detail = info.vllm_base_url ? `Primary: ${info.engine} @ ${info.vllm_base_url}` : `Engine: ${info.engine}`; pill.title = info.reachable ? `${detail} — reachable. No vendor LLM is contacted.` : onFallback ? `${detail} unreachable; running on ${info.fallback_engine} fallback.` : `${detail} — UNREACHABLE.`; } catch (e) { text.textContent = "backend unknown"; pill.dataset.state = "down"; pill.title = "Could not query /api/backend: " + e.message; } } refreshBackendPill(); setInterval(refreshBackendPill, 60000); // Subscribe to the shared highlight signal so vanilla-rendered citation // chips in the briefing prose mirror the highlight state driven by the // Lit (and vice versa). (async () => { const { highlightedDocId } = await import("/static/components/signals.js"); const apply = () => { const id = highlightedDocId.get(); document.querySelectorAll("#paragraph .cite").forEach(c => { c.classList.toggle("hl", c.dataset.srcId === id); }); }; // Lit-labs/signals exposes a subscribe / effect — try both shapes. if (typeof highlightedDocId.subscribe === "function") { highlightedDocId.subscribe(apply); } else { // Polyfill: poll on mutation. Cheap; signal updates are rare. const orig = highlightedDocId.set.bind(highlightedDocId); highlightedDocId.set = (v) => { orig(v); apply(); }; } })(); // "Generate auditable report" — snapshots the live map, packs the full // evidence (query / plan / per-specialist trace / final result / per-source // vintages / labels / urls), parks it in sessionStorage, opens /report. $("#reportBtn").addEventListener("click", () => { if (!LAST_RESULT) return; let mapPng = null; try { if (map && map.loaded()) { // preserveDrawingBuffer:false would force a one-frame render here map.triggerRepaint(); mapPng = map.getCanvas().toDataURL("image/png"); } } catch (e) { console.warn("map snapshot failed", e); } const pkg = { ...LAST_RESULT, plan: LAST_PLAN, trace: LAST_TRACE, mapPng, sourceLabels: SOURCE_LABELS, sourceUrls: SOURCE_URLS, sourceVintages: SOURCE_VINTAGES, stepLabels: STEP_LABELS, }; try { sessionStorage.setItem("riprap_report", JSON.stringify(pkg)); window.open("/report", "_blank"); } catch (e) { alert("Could not stash report payload (storage may be full): " + e.message); } });