"""Inunda โ€” Flood Validation Portfolio (Hugging Face Space). A thin Streamlit shell that injects a LIGHTWEIGHT episode INDEX (~10 KB) into a self-contained MapLibre + uPlot viewer. The heavy per-episode bundles (animated flood frames, gage hydrographs, chip overlays) are NOT inlined โ€” the viewer fetches each one LAZILY from the Hugging Face `resolve` CDN on click. This keeps the served page tiny no matter how many episodes exist, so the Space boots fast and never hits the inline-page size wall. Per-episode data is pre-rendered into assets/.json by build/preprocess.py and must be STRICT JSON (build/jsonsafe.py guarantees NaN/Infinity -> null so the browser's JSON.parse accepts the fetched bundle). """ import json import os import streamlit as st import streamlit.components.v1 as components HERE = os.path.dirname(os.path.abspath(__file__)) ASSETS = os.path.join(HERE, "assets") VIEWER = os.path.join(HERE, "viewer.html") # The viewer fetches `.json` from this repo's resolve CDN (CORS-safe: the Hub # echoes the requesting Origin into Access-Control-Allow-Origin). Keeping the # bundles in the Space repo means deploy.py already syncs them โ€” no extra repo. SPACE = "chrimerss/inunda-portfolio" BUNDLE_BASE = f"https://huggingface.co/spaces/{SPACE}/resolve/main/src/assets" st.set_page_config(page_title="Inunda ยท Flood Portfolio", page_icon="๐ŸŒŠ", layout="wide", initial_sidebar_state="collapsed") # Full-bleed: hide Streamlit chrome + the (unused) sidebar so the component fills the page. st.markdown(""" """, unsafe_allow_html=True) @st.cache_data(show_spinner=False) def build_index(sig): """Lightweight episode index `[{id,title,event,start,region,flood_type,gages, chips,chipsrc,v}, ...]` as a JSON string โ€” only what the sidebar list needs. The heavy frames/gages/chips stay in the per-episode files (fetched lazily). `v` (file mtime) is a cache-buster appended to the fetch URL on redeploy.""" out = [] for fn in sorted(os.listdir(ASSETS)): if not fn.endswith(".json"): continue p = os.path.join(ASSETS, fn) with open(p) as f: d = json.load(f, parse_constant=lambda _c: None) # tolerate any stray NaN gages = d.get("gages") or [] sf = d.get("sen1floods") or {} chips = sf.get("chips") or [] region = d.get("region") if not region and gages: # resolve the gage-name fallback here region = str(gages[0].get("name", "")).split(",")[-1].strip() or None out.append({ "id": fn[:-5], "title": d.get("title"), "event": d.get("event"), "start": d.get("start"), "region": region, "flood_type": d.get("flood_type"), "gages": len(gages), "chips": len(chips), "chipsrc": sf.get("source"), "v": int(os.path.getmtime(p)), }) return json.dumps(out, separators=(",", ":")) @st.cache_data(show_spinner=False) def viewer_html(mtime): with open(VIEWER) as f: return f.read() sig = tuple(sorted((fn, os.path.getmtime(os.path.join(ASSETS, fn))) for fn in os.listdir(ASSETS) if fn.endswith(".json"))) html = (viewer_html(os.path.getmtime(VIEWER)) .replace("INDEX_DATA_PLACEHOLDER", build_index(sig)) .replace("BUNDLE_BASE_PLACEHOLDER", BUNDLE_BASE)) components.html(html, height=900, scrolling=False)