// 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 = `
○
`;
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;
});
});