riprap-nyc / web /sveltekit /src /lib /components /map /MapLegend.svelte
msradam's picture
feat(map): wire TerraMind Buildings + Prithvi-Live layers to map
5bbf2fe verified
<script lang="ts">
import type { Tier } from '$lib/types/tier';
import TierGlyph from '$lib/components/glyphs/TierGlyph.svelte';
import TierBadge from '$lib/components/glyphs/TierBadge.svelte';
import type { StoneKey } from '$lib/types/card';
import { STONE_META, STONE_ORDER } from '$lib/types/card';
/** v0.4.5 §7 — LAYERS panel restructured to mirror Findings Stones.
*
* Each Stone is its own collapsed-but-visible group with one row per
* map layer keyed to that Stone. Master tier toggles (the live
* empirical / modeled / synthetic / proxy switches the map respects
* today) sit at the bottom of the panel; per-Stone rows inherit
* their tier's master state and display the resolved ON/OFF.
*
* Rows for layers that aren't yet wired into the map visibility
* pipeline render with a dimmed "off (not yet wired)" caption so the
* reader sees the catalog without thinking the toggle is broken. */
type MasterKey = 'empirical' | 'modeled' | 'synthetic' | 'proxy';
interface Props {
active: Record<MasterKey, boolean>;
/** Per-tier feature counts. Used to surface "no features" inline
* on each Stone row. `null` means caller wants the full catalog
* shown regardless of data-driven counts. */
featureCounts?: Record<MasterKey, number> | null;
onToggle: (key: MasterKey) => void;
}
let { active, featureCounts, onToggle }: Props = $props();
type LayerRow = {
label: string;
source: string;
tier: Tier;
/** When false, the row is purely catalog — the master tier toggle
* doesn't yet drive a real map source. Surfaced as "not yet wired". */
wired: boolean;
};
const STONE_LAYERS: Record<StoneKey, LayerRow[]> = {
cornerstone: [
{ label: 'Sandy Inundation Zone (2012)', source: 'NYC OEM', tier: 'empirical', wired: true },
{ label: 'FEMA / DEP scenarios', source: 'FEMA · NYC DEP', tier: 'modeled', wired: true },
{ label: 'Ida HWM points (2021)', source: 'USGS STN', tier: 'empirical', wired: true },
{ label: 'Microtopography (HAND/TWI)', source: 'USGS 3DEP', tier: 'proxy', wired: false },
],
keystone: [
{ label: 'MTA subway entrances', source: 'MTA Open Data', tier: 'empirical', wired: true },
{ label: 'NYCHA developments', source: 'NYC OD phvi-damg', tier: 'empirical', wired: true },
{ label: 'DOE schools', source: 'NYC DOE Locations', tier: 'empirical', wired: true },
{ label: 'DOH hospitals', source: 'NYS DOH vn5v-hh5r', tier: 'empirical', wired: true },
{ label: 'TerraMind Buildings (current)', source: 'msradam/TerraMind-NYC-Adapters', tier: 'synthetic', wired: true },
],
touchstone: [
{ label: '311 flood complaints', source: 'NYC 311', tier: 'proxy', wired: false },
{ label: 'FloodNet sensors', source: 'FloodNet NYC', tier: 'proxy', wired: true },
{ label: 'TerraMind LULC (current)', source: 'msradam/TerraMind-NYC-Adapters', tier: 'synthetic', wired: true },
{ label: 'Prithvi-NYC-Pluvial flood pred.', source: 'msradam/Prithvi-EO-2.0-NYC-Pluvial', tier: 'modeled', wired: true },
],
lodestone: [], // intentional — surfaced as the explicit absence row
capstone: [], // not a map layer; surfaced as "not a map layer"
};
/** Resolve a row's ON state from the master tier toggle. */
function isOn(row: LayerRow): boolean {
return !!active[row.tier];
}
function tally(stone: StoneKey): number {
return STONE_LAYERS[stone].length;
}
// Active tier toggles — rendered as small chips at the bottom of the
// panel so the user can still flip the four master switches.
const MASTERS: { k: MasterKey; tier: Tier; label: string }[] = [
{ k: 'empirical', tier: 'empirical', label: 'EMP' },
{ k: 'modeled', tier: 'modeled', label: 'MOD' },
{ k: 'proxy', tier: 'proxy', label: 'PRX' },
{ k: 'synthetic', tier: 'synthetic', label: 'SYN' },
];
// featureCounts is intentionally accepted but not used in the catalog
// view — the catalog shows every row regardless of live counts. Kept
// in the prop signature so callers don't have to change.
</script>
<aside class="layers-panel" aria-label="Map layers grouped by Stone">
<div class="layers-head">
<span class="section-label">Layers · grouped by Stone</span>
</div>
{#each STONE_ORDER as stone (stone)}
<details class="layers-group region-{stone}" open>
<summary>
<span class="layers-caret" aria-hidden="true"></span>
<span class="layers-stone-name">{STONE_META[stone].name}</span>
<span class="layers-stone-tag">— {STONE_META[stone].tag}</span>
{#if tally(stone) > 0}
<span class="layers-count">{tally(stone)}</span>
{/if}
</summary>
<ul class="layers-list">
{#if stone === 'lodestone'}
<li class="layers-row layers-row-empty">
<span class="layers-empty-text">no map layers — see Findings cards</span>
</li>
{:else if stone === 'capstone'}
<li class="layers-row layers-row-empty">
<span class="layers-empty-text">not a map layer</span>
</li>
{:else}
{#each STONE_LAYERS[stone] as row, i (i)}
<li class="layers-row" class:dim={!row.wired}>
<span class="layers-glyph" aria-hidden="true">
<TierGlyph tier={row.tier} size={11} color="var(--tier-{row.tier})" />
</span>
<span class="layers-text">
<span class="layers-label">{row.label}</span>
<span class="layers-meta">{row.source} · <TierBadge tier={row.tier} compact /></span>
</span>
<span class="layers-state">
{#if !row.wired}
<span class="layers-state-dim" title="Not yet wired to map source">off · catalog</span>
{:else if isOn(row)}
on
{:else}
off
{/if}
</span>
</li>
{/each}
{/if}
</ul>
</details>
{/each}
<!-- Master tier toggles. These are the actual switches the map honours
today; each Stone row above resolves ON/OFF from these. -->
<div class="layers-masters" role="group" aria-label="Master tier toggles">
<span class="section-label">Tier toggles</span>
<div class="layers-master-row">
{#each MASTERS as m (m.k)}
<button
type="button"
class="layers-master"
class:is-on={active[m.k]}
aria-pressed={active[m.k]}
onclick={() => onToggle(m.k)}
>
<TierGlyph tier={m.tier} size={11} color="var(--tier-{m.tier})" />
<span>{m.label}</span>
<span class="layers-master-state">{active[m.k] ? 'ON' : 'OFF'}</span>
</button>
{/each}
</div>
</div>
</aside>
<style>
.layers-panel {
background: var(--paper);
border: 1px solid var(--rule-soft);
padding: var(--s-3) var(--s-4) var(--s-4);
display: flex;
flex-direction: column;
gap: var(--s-3);
font-family: var(--font-sans);
}
.layers-head { padding-bottom: 4px; }
.layers-group {
border-top: 1px solid var(--rule-soft);
padding-top: var(--s-2);
/* Stone-tinted left rule (v0.4.5 §9 sibling treatment). */
border-left: 3px solid var(--stone-tint, var(--rule-soft));
padding-left: var(--s-3);
}
.layers-group.region-cornerstone { --stone-tint: var(--stone-cornerstone); }
.layers-group.region-keystone { --stone-tint: var(--stone-keystone); }
.layers-group.region-touchstone { --stone-tint: var(--stone-touchstone); }
.layers-group.region-lodestone { --stone-tint: var(--stone-lodestone); }
.layers-group.region-capstone { --stone-tint: var(--stone-capstone); }
.layers-group summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: baseline;
gap: var(--s-2);
padding: 4px 0;
}
.layers-group summary::-webkit-details-marker { display: none; }
.layers-caret {
font-size: 10px;
color: var(--ink-tertiary);
transition: transform 200ms ease;
}
.layers-group:not([open]) .layers-caret { transform: rotate(-90deg); }
.layers-stone-name {
font-family: var(--font-serif);
font-style: italic;
font-size: 16px;
color: var(--ink);
}
.layers-stone-tag {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-tertiary);
letter-spacing: 0.04em;
}
.layers-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
color: var(--ink-tertiary);
letter-spacing: 0.05em;
text-transform: lowercase;
}
.layers-list {
list-style: none;
margin: 4px 0 var(--s-2);
padding: 0;
display: flex;
flex-direction: column;
}
.layers-row {
display: grid;
grid-template-columns: 16px 1fr auto;
gap: var(--s-2);
align-items: center;
padding: 4px 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink);
border-bottom: 1px dotted var(--rule-soft);
}
.layers-row:last-child { border-bottom: 0; }
.layers-row.dim { opacity: 0.7; }
.layers-glyph { display: inline-flex; align-items: center; }
.layers-text { display: flex; flex-direction: column; gap: 2px; }
.layers-label {
color: var(--ink);
font-family: var(--font-sans);
font-size: 12px;
}
.layers-meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ink-tertiary);
display: inline-flex;
align-items: center;
gap: 4px;
}
.layers-state {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.05em;
color: var(--ink);
text-transform: uppercase;
}
.layers-state-dim {
color: var(--ink-tertiary);
text-transform: lowercase;
font-style: italic;
}
.layers-row-empty .layers-empty-text {
grid-column: 1 / -1;
color: var(--ink-tertiary);
font-style: italic;
font-family: var(--font-mono);
font-size: 11px;
}
.layers-masters {
border-top: 1px solid var(--rule-soft);
padding-top: var(--s-2);
}
.layers-master-row {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.layers-master {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--paper);
border: 1px solid var(--rule-soft);
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.05em;
color: var(--ink);
}
.layers-master.is-on { background: var(--paper-deep); border-color: var(--ink); }
.layers-master-state {
margin-left: 4px;
color: var(--ink-tertiary);
font-size: 9px;
}
.layers-master.is-on .layers-master-state { color: var(--ink); }
</style>