| |
| |
|
|
| const $ = (s) => document.querySelector(s); |
|
|
| const STEP_LABELS = { |
| |
| 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"], |
| |
| 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"], |
| |
| 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"], |
| |
| 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", |
| |
| |
| |
| 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)", |
| }; |
|
|
| |
| |
| 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", |
| }; |
|
|
| |
| |
| |
| 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", |
| }; |
|
|
| |
| |
| |
|
|
| let map = null; |
| let mapInit = false; |
|
|
| function ensureMap() { |
| if (mapInit) return; |
| mapInit = true; |
| map = new maplibregl.Map({ |
| container: "map", |
| style: { |
| version: 8, |
| |
| |
| 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 }, |
| |
| |
| |
| preserveDrawingBuffer: true, |
| }); |
| map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "top-right"); |
| map.on("load", initMapSources); |
| } |
|
|
| function initMapSources() { |
| |
| 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 } }); |
|
|
| |
| |
| |
| |
| |
| 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 } }); |
|
|
| |
| |
| |
| |
| 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(`<b>TerraMind synthetic land-cover</b><br>` + |
| `Class: ${escapeHtml(p.label || "")} (tentative)<br>` + |
| `<i>Synthesised from LiDAR DEM, not observed.</i>`) |
| .addTo(map); |
| }); |
|
|
| |
| 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 } }); |
|
|
| |
| 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( |
| `<b>${escapeHtml(p.address || "(unknown)")}</b><br>` + |
| `${p.job_type} · ${p.in_sandy === 'true' ? 'Sandy zone' : 'outside Sandy'}<br>` + |
| `DEP class: ${p.dep_max_class}`) |
| .addTo(map); |
| }); |
|
|
| |
| 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 } }); |
|
|
| |
| |
| |
| 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] } }); |
|
|
| |
| 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(`<b>311 complaint</b><br>${escapeHtml(p.descriptor || "")}<br>` + |
| `${escapeHtml(p.date || "")}<br>${escapeHtml(p.address || "")}`) |
| .addTo(map); |
| }); |
|
|
| |
| |
| 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(`<b>FloodNet sensor</b><br>${escapeHtml(p.name || p.deployment_id || "")}`) |
| .addTo(map); |
| }); |
|
|
| |
| 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(`<b>USGS Ida 2021 high-water mark</b><br>` + |
| `${escapeHtml(p.site || "(unnamed)")}<br>` + |
| `Elevation: ${p.elev_ft ?? "?"} ft<br>` + |
| `Height above ground: ${p.height_above_gnd_ft ?? "?"} ft`) |
| .addTo(map); |
| }); |
|
|
| |
| 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(`<b>NOAA tide gauge</b><br>${escapeHtml(p.name || "")}<br>` + |
| `Observed water level: ${p.observed_ft ?? "?"} ft MLLW<br>` + |
| `Residual (≈ surge): ${p.residual_ft ?? "?"} ft`) |
| .addTo(map); |
| }); |
| } |
|
|
| |
| |
| 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: {} }] }); |
| |
| 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 {} |
| |
| |
| const live = d.prithvi_live; |
| if (live && live.ok && live.polygons_geojson && map.getSource("prithvi_live")) { |
| map.getSource("prithvi_live").setData(live.polygons_geojson); |
| } |
|
|
| |
| const tm = d.terramind; |
| if (tm && tm.ok && tm.polygons_geojson && map.getSource("terramind_lulc")) { |
| map.getSource("terramind_lulc").setData(tm.polygons_geojson); |
| } |
|
|
| |
| |
| |
| 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 }, |
| })), |
| }); |
| } |
|
|
| |
| 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 || "", |
| }, |
| })), |
| }); |
| } |
|
|
| |
| 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 || "", |
| }, |
| })), |
| }); |
| } |
|
|
| |
| 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, |
| }, |
| })), |
| }); |
| } |
|
|
| |
| 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); |
| |
| |
| |
| const live = d.prithvi_live; |
| if (live && live.ok && live.polygons_geojson && map.getSource("prithvi_live")) { |
| map.getSource("prithvi_live").setData(live.polygons_geojson); |
| } |
| |
| 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); |
| 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) { |
| |
| map.flyTo({ center: [-74.0, 40.7], zoom: 10, duration: 700 }); |
| } |
|
|
| |
| |
| |
| async function incrementallyFillMap(step) { |
| if (!map || !map.loaded()) { |
| map.once("load", () => incrementallyFillMap(step)); |
| return; |
| } |
| const r = step.result || {}; |
| |
| 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; |
| } |
| |
| 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; |
| } |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
|
|
| function escapeHtml(s) { |
| return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| } |
|
|
| let CITE_INDEX = {}; |
| |
| |
| |
| 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 `<span class="cite" data-src-id="${norm}" data-src-n="${n}" title="${_resolveSourceLabel(norm)} — click to highlight in Sources">${n}</span>`; |
| }); |
| } |
|
|
| |
| |
| |
| |
| async function renderSources() { |
| const el = document.getElementById("sourcesFooter"); |
| if (!el) return; |
| |
| await customElements.whenDefined("r-sources-footer"); |
| el.labels = SOURCE_LABELS; |
| el.urls = SOURCE_URLS; |
| el.vintages = SOURCE_VINTAGES; |
| |
| |
| const { citeIndex } = await import("/static/components/signals.js"); |
| citeIndex.set({ ...CITE_INDEX }); |
| } |
|
|
| function renderMarkdown(text) { |
| |
| |
| |
| |
| |
| 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, "<strong>$1</strong>"); |
| if (safe) out.push(`<p class="rsum-p">${safe}</p>`); |
| para = []; |
| }; |
| const flushBullets = () => { |
| if (!bullets.length) return; |
| const items = bullets.map(b => { |
| const safe = escapeHtml(b.trim()).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); |
| return `<li>${safe}</li>`; |
| }).join(""); |
| out.push(`<ul class="rsum-list">${items}</ul>`); |
| bullets = []; |
| }; |
| |
| |
| const expanded = []; |
| for (const line of lines) { |
| if (line.trim().startsWith("- ") && line.includes(" - ", 2)) { |
| |
| 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(`<h4 class="rsum-h">${escapeHtml(m[1])}</h4>`); |
| continue; |
| } |
| if (/^\s*[-*]\s+/.test(line)) { |
| flushPara(); |
| bullets.push(line.replace(/^\s*[-*]\s+/, "")); |
| } else { |
| flushBullets(); |
| para.push(line); |
| } |
| } |
| flushPara(); flushBullets(); |
| return out.join(""); |
| } |
|
|
| |
| |
| |
| 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); } |
|
|
| |
| |
| |
|
|
| 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 = ` |
| <div class="headline-stat">${sandyPct}</div> |
| <div class="headline-sub">of the neighborhood is inside the 2012 Sandy Inundation Zone</div> |
| <dl class="facts-grid"> |
| <dt>DEP Extreme 2080</dt><dd>${dep80!=null ? (dep80*100).toFixed(1)+"%" : "—"}</dd> |
| <dt>DEP Moderate 2050</dt><dd>${dep50!=null ? (dep50*100).toFixed(1)+"%" : "—"}</dd> |
| <dt>311 (3 yr)</dt><dd>${c.n ?? "—"} flood complaints</dd> |
| <dt>HAND median</dt><dd>${m.hand_median_m != null ? m.hand_median_m+" m" : "—"}</dd> |
| <dt>HAND < 1 m fraction</dt><dd>${m.frac_hand_lt1 != null ? (m.frac_hand_lt1*100).toFixed(0)+"%" : "—"}</dd> |
| <dt>TWI median</dt><dd>${m.twi_median ?? "—"}</dd> |
| </dl>`; |
| } |
|
|
| function renderDevFacts(d, body) { |
| $("#factsTitle").textContent = `Active construction — ${d.target?.nta_name || ""}`; |
| const ds = d.dob_summary || {}; |
| body.innerHTML = ` |
| <div class="headline-stat">${ds.n_in_sandy ?? 0} <span style="color:var(--text-muted); font-size:18px; font-weight:400;">/ ${ds.n_total ?? 0}</span></div> |
| <div class="headline-sub">active projects inside the Sandy zone</div> |
| <dl class="facts-grid"> |
| <dt>Total active</dt><dd>${ds.n_total ?? 0}</dd> |
| <dt>In any DEP scenario</dt><dd>${ds.n_in_dep_any ?? 0}</dd> |
| <dt>In severe DEP (≥1 ft)</dt><dd>${ds.n_in_dep_severe ?? 0}</dd> |
| <dt>By job type</dt><dd>${Object.entries(ds.by_job_type || {}).map(([k,v]) => `${v} ${k}`).join(", ")}</dd> |
| </dl>`; |
| } |
|
|
| 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 = ` |
| <div class="headline-stat">${a.n_active ?? 0} alerts</div> |
| <div class="headline-sub">active flood-relevant NWS alerts at this point</div> |
| <dl class="facts-grid"> |
| <dt>Tide gauge</dt><dd>${t.station_name || "—"}</dd> |
| <dt>Observed</dt><dd>${t.observed_ft_mllw != null ? t.observed_ft_mllw+" ft MLLW" : "—"}</dd> |
| <dt>Residual</dt><dd>${r != null ? (r >= 0 ? "+" : "")+r+" ft" : "—"}</dd> |
| <dt>Nearest ASOS</dt><dd>${o.station_id || "—"}</dd> |
| <dt>Precip 1h</dt><dd>${o.precip_last_hour_mm != null ? o.precip_last_hour_mm+" mm" : "—"}</dd> |
| <dt>TTM peak (next 9.6h)</dt><dd>${ttm.forecast_peak_ft != null ? ttm.forecast_peak_ft+" ft" : "—"}</dd> |
| </dl>`; |
| } |
|
|
| 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 = ` |
| <div class="headline-sub">${geo.address || "—"}</div> |
| <dl class="facts-grid"> |
| <dt>Sandy zone</dt><dd>${d.sandy ? "INSIDE" : "outside"}</dd> |
| <dt>DEP Extreme 2080</dt><dd>${e80.depth_label || "—"}</dd> |
| <dt>HAND</dt><dd>${m.hand_m != null ? m.hand_m+" m" : "—"}</dd> |
| <dt>TWI</dt><dd>${m.twi ?? "—"}</dd> |
| <dt>Elev pct (200m)</dt><dd>${m.rel_elev_pct_200m ?? "—"}</dd> |
| <dt>311 (5y, 200m)</dt><dd>${(d.nyc311 || {}).n ?? "—"}</dd> |
| </dl>`; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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 = ""; |
| } |
|
|
| |
| |
| |
|
|
| 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; |
| |
| const empirical = ` |
| <div class="legend-row"><span class="legend-swatch fill" style="background:#fc5d52; opacity:0.4"></span>Sandy 2012 extent</div> |
| <div class="legend-row"><span class="legend-swatch fill" style="background:#1642DF; opacity:0.4"></span>DEP Extreme-2080</div>`; |
| const points = ` |
| <div class="legend-row"><span class="legend-swatch" style="background:#fc5d52; border-radius:50%"></span>311 — sewer backup</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#f59e0b; border-radius:50%"></span>311 — catch basin</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#1642DF; border-radius:50%"></span>311 — street flooding</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#48c6eb; border:2px solid #1aa3c8; border-radius:50%"></span>FloodNet sensor</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#ea580c; border:1px solid #7c2d12; border-radius:50%"></span>Ida 2021 high-water mark</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#0ea5e9; border:2px solid #fff; border-radius:50%; outline:1px solid #ccc"></span>NOAA tide gauge</div>`; |
| |
| |
| const synthetic = ` |
| <div style="font-weight:700; font-size:9.5px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted); margin-top:6px; margin-bottom:2px">Synthetic priors (not observed)</div> |
| <div class="legend-row"><span class="legend-swatch fill" style="background:#48c6eb; opacity:0.45"></span>Prithvi-EO 2.0 — live water mask</div> |
| <div class="legend-row"><span class="legend-swatch fill" style="background:#16a34a; opacity:0.30; border:1px dashed #16a34a"></span>TerraMind — synthetic LULC (DEM→ESRI Land Cover, dashed = generated)</div>`; |
|
|
| if (intent === "development_check") { |
| el.innerHTML = ` |
| <div style="font-weight:700; font-size:9.5px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted); margin-bottom:4px">Active permits</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#fc5d52"></span>Inside Sandy zone</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#1642DF"></span>DEP deep band (≥1 ft)</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#568adf"></span>DEP nuisance band</div> |
| <div class="legend-row"><span class="legend-swatch" style="background:#1a8754"></span>No flood layer</div> |
| <div class="legend-row" style="margin-top:6px">${empirical}</div>${synthetic}`; |
| el.style.display = ""; |
| } else if (intent === "neighborhood") { |
| el.innerHTML = `${empirical} |
| <div class="legend-row"><span class="legend-swatch fill" style="background:transparent; border:2px solid #0b3b6b"></span>NTA boundary</div>${synthetic}`; |
| el.style.display = ""; |
| } else if (intent === "single_address") { |
| el.innerHTML = ` |
| <div class="legend-row"><span class="legend-swatch" style="background:#0b3b6b"></span>Address</div>${empirical}${points}${synthetic}`; |
| el.style.display = ""; |
| } else { |
| el.style.display = "none"; |
| } |
| } |
|
|
| |
| |
| |
| 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); |
|
|
| |
| |
| if (intent === "single_address") { |
| const c = computeComposite(d); |
| const m = tierMeta(c.tier); |
| const titleEl = $("#briefTitle"); |
| const floor = c.floorApplied ? ' <span class="tier-floor">empirical floor</span>' : ""; |
| titleEl.innerHTML += ` <span class="tier-chip t-${m.tier}" title="${escapeHtml(m.help)}"> |
| Tier ${m.tier} · ${escapeHtml(m.label)}${floor} |
| </span>`; |
| } |
|
|
| |
| |
| |
| 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 += |
| ` <span class="mellea-badge ${cls}" title="${escapeHtml(tip)}">` + |
| `<span class="ico">✓</span>Mellea ${passed}/${total}` + |
| (m.rerolls > 0 ? ` · ${m.rerolls} reroll${m.rerolls === 1 ? "" : "s"}` : "") + |
| `</span>`; |
| } |
| if (intent === "single_address" && d.geocode) { |
| if (d.geocode.borough) meta.push(`<span class="brief-meta-k">borough</span> <span class="brief-meta-v">${escapeHtml(d.geocode.borough)}</span>`); |
| if (d.geocode.bbl) meta.push(`<span class="brief-meta-k">bbl</span> <span class="brief-meta-v">${escapeHtml(d.geocode.bbl)}</span>`); |
| } else if (d.target && d.target.borough) { |
| meta.push(`<span class="brief-meta-k">borough</span> <span class="brief-meta-v">${escapeHtml(d.target.borough)}</span>`); |
| if (d.target.nta_code) meta.push(`<span class="brief-meta-k">nta</span> <span class="brief-meta-v">${escapeHtml(d.target.nta_code)}</span>`); |
| } |
| if (d.total_s != null) meta.push(`<span class="brief-meta-k">runtime</span> <span class="brief-meta-v">${d.total_s}s</span>`); |
| meta.push(`<span class="brief-meta-k">assessed</span> <span class="brief-meta-v">${new Date().toISOString().slice(0,16).replace("T"," ")}</span>`); |
| $("#briefMeta").innerHTML = meta.join('<span style="color:var(--text-faint)">·</span>'); |
| } |
|
|
| |
| |
| |
|
|
| function renderPlan(p) { |
| const pillCls = INTENT_PILL_CLASS[p.intent] || ""; |
| $("#plannerRow").innerHTML = ` |
| <div class="planner-box"> |
| <div class="planner-key">Planner</div> |
| <div><span class="intent-pill ${pillCls}">${escapeHtml(p.intent)}</span></div> |
| <div class="planner-key">Targets</div> |
| <div class="planner-val">${(p.targets || []).map(t => escapeHtml(t.type) + ":" + escapeHtml(t.text)).join(", ") || "(none)"}</div> |
| <div class="planner-key">Specialists</div> |
| <div class="planner-val">${(p.specialists || []).join(", ")}</div> |
| <div class="planner-rationale">"${escapeHtml(p.rationale || "")}"</div> |
| </div>`; |
| } |
|
|
| |
| |
| |
|
|
| let currentEs = null; |
| |
| |
| |
| 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 = `<div class="planner-streaming"></div>`; |
| 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); |
| }; |
| |
| |
| |
| |
| |
| |
| 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)); }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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)) { |
| |
| |
| 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(); |
| }); |
| |
| |
| |
| 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 = `<strong>↻ Mellea reroll</strong> — attempt ${(d.attempt|0)+1} failed: <code>${d.failed.join(", ")}</code>. Re-drafting…`; |
| banner.style.display = ""; |
| } else { |
| banner.className = "mellea-banner pass"; |
| banner.innerHTML = `<strong>✓ Mellea</strong> — 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); |
| |
| 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; }); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| $("#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); } |
|
|
| |
| |
| |
| 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); |
|
|
| |
| |
| |
| (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); |
| }); |
| }; |
| |
| if (typeof highlightedDocId.subscribe === "function") { |
| highlightedDocId.subscribe(apply); |
| } else { |
| |
| const orig = highlightedDocId.set.bind(highlightedDocId); |
| highlightedDocId.set = (v) => { orig(v); apply(); }; |
| } |
| })(); |
|
|
| |
| |
| |
| $("#reportBtn").addEventListener("click", () => { |
| if (!LAST_RESULT) return; |
| let mapPng = null; |
| try { |
| if (map && map.loaded()) { |
| |
| 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); |
| } |
| }); |
|
|