// Riprap — Compare mode. Two addresses, parallel FSM runs, shared map. 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 buffer"], microtopo_lidar: ["LiDAR terrain (DEM + TWI + HAND)", "USGS 3DEP DEM + whitebox hydrology"], ida_hwm_2021: ["Ida 2021 high-water marks", "USGS empirical post-event extent"], prithvi_eo_v2: ["Prithvi-EO 2.0 (300M, NASA/IBM)", "Sen1Floods11 satellite water segmentation"], rag_granite_embedding: ["Granite Embedding 278M (RAG)", "policy corpus retrieval"], reconcile_granite41: ["Granite 4.1 reconcile (local)", "document-grounded synthesis"], }; const STEPS_ORDER = [ "geocode", "sandy_inundation", "dep_stormwater", "floodnet", "nyc311", "microtopo_lidar", "ida_hwm_2021", "prithvi_eo_v2", "rag_granite_embedding", "reconcile_granite41", ]; const SOURCE_LABELS = { geocode: "NYC DCP Geosearch", sandy: "NYC OpenData 5xsi-dfpx — Sandy 2012 inundation", dep_extreme_2080: "NYC DEP Stormwater — Extreme 3.66 in/hr + 2080 SLR", dep_moderate_2050: "NYC DEP Stormwater — Moderate 2.13 in/hr + 2050 SLR", dep_moderate_current: "NYC DEP Stormwater — Moderate 2.13 in/hr current", floodnet: "FloodNet NYC — live ultrasonic sensor network", nyc311: "NYC 311 (Socrata erm2-nwe9) — flood descriptors", microtopo: "USGS 3DEP 30 m DEM via py3dep", ida_hwm: "USGS STN — Hurricane Ida 2021 HWMs (Event 312, NY)", prithvi_water: "Prithvi-EO 2.0 (300M, NASA/IBM) Sen1Floods11 — satellite water segmentation", rag_dep_2013: "NYC DEP Wastewater Resiliency Plan (2013)", rag_nycha: "NYCHA — Flood Resilience: Lessons Learned", rag_coned: "Con Edison Climate Change Resilience Plan (Case 22-E-0222)", rag_mta: "MTA Climate Resilience Roadmap (Oct 2025)", rag_comptroller: "NYC Comptroller — \"Is NYC Ready for Rain?\" (2024)", }; const $ = (s) => document.querySelector(s); let evtSrc = null; let map = null; let mapInit = false; const MAP_STYLE = { version: 8, sources: { carto: { type: "raster", tiles: ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"], tileSize: 256, attribution: "© OpenStreetMap contributors © CARTO", }, }, layers: [ { id: "bg", type: "background", paint: { "background-color": "#fafbfd" } }, { id: "carto", type: "raster", source: "carto" }, ], }; function ensureMap() { if (mapInit) return; mapInit = true; map = new maplibregl.Map({ container: "map", style: MAP_STYLE, center: [-74.0, 40.72], zoom: 10, attributionControl: { compact: true }, }); map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "top-right"); map.on("load", () => { for (const sideKey of ["a", "b"]) { map.addSource("sandy_" + sideKey, { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "sandy_" + sideKey + "-fill", type: "fill", source: "sandy_" + sideKey, paint: { "fill-color": "#fc5d52", "fill-opacity": 0.22 } }); map.addSource("dep_" + sideKey, { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "dep_" + sideKey + "-fill", type: "fill", source: "dep_" + sideKey, paint: { "fill-color": ["match", ["get", "Flooding_Category"], 1, "#568adf", 2, "#1642DF", 3, "#031553", "#568adf"], "fill-opacity": 0.28 } }); map.addSource("fn_" + sideKey, { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "fn_" + sideKey + "-circles", type: "circle", source: "fn_" + sideKey, paint: { "circle-radius": 5, "circle-color": ["case", [">", ["get", "n_events_3y"], 0], "#fc5d52", "#1a8754"], "circle-stroke-color": "#ffffff", "circle-stroke-width": 1.5, } }); } map.addSource("addr_a", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "addr_a-marker", type: "circle", source: "addr_a", paint: { "circle-radius": 9, "circle-color": "#1642DF", "circle-stroke-color": "#fff", "circle-stroke-width": 2.5 } }); map.addSource("addr_b", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "addr_b-marker", type: "circle", source: "addr_b", paint: { "circle-radius": 9, "circle-color": "#9333ea", "circle-stroke-color": "#fff", "circle-stroke-width": 2.5 } }); }); } function resetSide(side) { const ul = document.getElementById("steps" + side.toUpperCase()); ul.innerHTML = ""; for (const sid of STEPS_ORDER) { const [lbl, hint] = STEP_LABELS[sid] || [sid, ""]; const li = document.createElement("li"); li.id = `step-${side}-${sid}`; li.className = "pending"; li.innerHTML = `
${lbl}
${hint}
`; ul.appendChild(li); } document.getElementById("step-" + side + "-" + STEPS_ORDER[0]).classList.replace("pending", "running"); document.getElementById("report" + side.toUpperCase()).classList.add("hidden"); document.getElementById("paragraph" + side.toUpperCase()).innerHTML = ""; document.getElementById("glance" + side.toUpperCase()).innerHTML = ""; document.getElementById("sources" + side.toUpperCase()).innerHTML = ""; } function markStep(side, stepId, ev) { const li = document.getElementById(`step-${side}-${stepId}`); if (!li) return; li.className = ev.ok ? "ok" : "err"; li.querySelector(".icon").textContent = ev.ok ? "✓" : "✗"; if (ev.elapsed_s != null) { li.querySelector(".time").textContent = ev.elapsed_s.toFixed(2) + "s"; } if (ev.result) { let div = li.querySelector(".result"); if (!div) { div = document.createElement("div"); div.className = "result"; li.appendChild(div); } div.textContent = formatResult(ev.result); } else if (ev.err) { let div = li.querySelector(".result"); if (!div) { div = document.createElement("div"); div.className = "result"; li.appendChild(div); } div.textContent = "error: " + ev.err; } const idx = STEPS_ORDER.indexOf(stepId); if (idx >= 0 && idx + 1 < STEPS_ORDER.length) { const next = document.getElementById(`step-${side}-${STEPS_ORDER[idx + 1]}`); if (next && next.classList.contains("pending")) next.classList.replace("pending", "running"); } } function formatResult(r) { if (typeof r !== "object") return String(r); return Object.entries(r) .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`) .join(" · "); } function renderParagraph(side, text) { const html = (text || "").replace(/\[([a-z0-9_]+)\]/gi, (_, d) => `[${d}]`); document.getElementById("paragraph" + side.toUpperCase()).innerHTML = html; } function renderGlance(side, ev) { const ul = document.getElementById("glance" + side.toUpperCase()); if (!ul) return; const rows = []; if (ev.sandy) { rows.push({c: "hit", mark: "■", html: "Inside Sandy 2012 inundation extent"}); } else { rows.push({c: "miss", mark: "□", html: "Outside Sandy 2012 inundation extent"}); } const dep = ev.dep || {}; const depHits = Object.entries(dep).filter(([_, v]) => (v.depth_class || 0) > 0); if (depHits.length) { for (const [scen, v] of depHits) { const lbl = scen.replace("dep_", "").replace(/_/g, " "); rows.push({c: "hit", mark: "■", html: `Inside DEP ${lbl} — ${v.depth_label}`}); } } else { rows.push({c: "miss", mark: "□", html: "Outside all DEP stormwater scenarios"}); } const fn = ev.floodnet; if (fn && fn.n_sensors) { if (fn.n_flood_events_3y > 0) { const peak = fn.peak_event; const peakStr = peak && peak.max_depth_mm ? `, peak ${peak.max_depth_mm} mm` : ''; rows.push({c: "hit", mark: "■", html: `${fn.n_flood_events_3y} FloodNet events (3 yr)${peakStr}`}); } else { rows.push({c: "miss", mark: "□", html: `${fn.n_sensors} FloodNet sensor(s), no events`}); } } const ida = ev.ida_hwm; if (ida && ida.n_within_radius > 0) { const ht = ida.max_height_above_gnd_ft != null ? `up to ${ida.max_height_above_gnd_ft} ft above ground` : ''; rows.push({c: "hit", mark: "■", html: `${ida.n_within_radius} Ida 2021 HWMs ≤${ida.radius_m} m${ht ? ', ' + ht : ''}`}); } const mt = ev.microtopo; if (mt) { rows.push({c: "note", mark: "◆", html: `Elevation ${mt.point_elev_m} m, lower than ${mt.rel_elev_pct_200m}% of nearby (200 m)`}); } const c311 = ev.nyc311; if (c311 && c311.n > 0) { rows.push({c: "note", mark: "◆", html: `${c311.n} 311 flood complaints ≤${c311.radius_m} m, ${c311.years} yr`}); } ul.innerHTML = rows .map(r => `
  • ${r.mark}${r.html}
  • `).join(""); } function renderSources(side, ev, paraText) { const fired = new Set([...(paraText || "").matchAll(/\[([a-z0-9_]+)\]/g)].map(m => m[1])); const order = [ "sandy", "dep_extreme_2080", "dep_moderate_2050", "dep_moderate_current", "floodnet", "ida_hwm", "microtopo", "nyc311", "rag_dep_2013", "rag_nycha", "rag_coned", "rag_mta", "rag_comptroller", ]; const present = new Set(); if (ev.sandy) present.add("sandy"); for (const [k, v] of Object.entries(ev.dep || {})) { if ((v.depth_class || 0) > 0) present.add(k); } if (ev.floodnet && ev.floodnet.n_sensors > 0) present.add("floodnet"); if (ev.ida_hwm && ev.ida_hwm.n_within_radius > 0) present.add("ida_hwm"); if (ev.microtopo) present.add("microtopo"); if (ev.nyc311 && ev.nyc311.n > 0) present.add("nyc311"); if (ev.rag) for (const h of ev.rag) present.add(h.doc_id); const ol = document.getElementById("sources" + side.toUpperCase()); ol.innerHTML = order.filter(d => present.has(d)).map(d => { const label = SOURCE_LABELS[d] || d; const dim = fired.has(d) ? "" : ' style="opacity:0.5"'; return `${d}${label}`; }).join(""); } async function updateMapForSide(side, geo) { ensureMap(); if (!map.loaded()) await new Promise(res => map.once("load", res)); const sideKey = side.toLowerCase(); map.getSource("addr_" + sideKey).setData({ type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [geo.lon, geo.lat] }, properties: {} }], }); const url = (p) => `${p}?lat=${geo.lat}&lon=${geo.lon}&r=1500`; const [sandy, dep, fn] = await Promise.all([ fetch(url("/api/layers/sandy")).then(r => r.json()).catch(() => null), fetch(url("/api/layers/dep_extreme_2080")).then(r => r.json()).catch(() => null), fetch(`/api/floodnet_near?lat=${geo.lat}&lon=${geo.lon}&r=1000`).then(r => r.json()).catch(() => null), ]); if (sandy) map.getSource("sandy_" + sideKey).setData(sandy); if (dep) map.getSource("dep_" + sideKey).setData(dep); if (fn) map.getSource("fn_" + sideKey).setData(fn); } function fitBoth(ga, gb) { if (!ga || !gb || !map.loaded()) return; const bounds = new maplibregl.LngLatBounds() .extend([ga.lon, ga.lat]).extend([gb.lon, gb.lat]); map.fitBounds(bounds, { padding: 80, duration: 800, maxZoom: 13 }); } let geoA = null, geoB = null; document.querySelectorAll(".chip[data-a]").forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); document.getElementById("qa").value = btn.getAttribute("data-a"); document.getElementById("qb").value = btn.getAttribute("data-b"); document.getElementById("cform").requestSubmit(); }); }); document.getElementById("cform").addEventListener("submit", (e) => { e.preventDefault(); const a = document.getElementById("qa").value.trim(); const b = document.getElementById("qb").value.trim(); if (!a || !b) return; document.getElementById("aTitle").textContent = a; document.getElementById("bTitle").textContent = b; resetSide("a"); resetSide("b"); ensureMap(); geoA = geoB = null; document.getElementById("cgo").disabled = true; if (evtSrc) evtSrc.close(); evtSrc = new EventSource(`/api/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`); evtSrc.addEventListener("step", (msg) => { const ev = JSON.parse(msg.data); markStep(ev.side, ev.step, ev); }); evtSrc.addEventListener("final", (msg) => { const ev = JSON.parse(msg.data); const side = ev.side; document.getElementById("report" + side.toUpperCase()).classList.remove("hidden"); if (ev.geocode) { if (side === "a") geoA = ev.geocode; else geoB = ev.geocode; updateMapForSide(side, ev.geocode).then(() => fitBoth(geoA, geoB)); } if (ev.paragraph) renderParagraph(side, ev.paragraph); renderGlance(side, ev); renderSources(side, ev, ev.paragraph || ""); }); evtSrc.addEventListener("done", () => { document.getElementById("cgo").disabled = false; evtSrc.close(); }); evtSrc.addEventListener("error", () => { document.getElementById("cgo").disabled = false; }); });