Spaces:
Running on Zero
Running on Zero
| """ | |
| Streamlit river network explorer β click directly on the river line (or | |
| use the slider) to read interpolated elevation and estimated groundwater | |
| level at that point. | |
| Click support uses Streamlit's native chart-selection feature | |
| (st.plotly_chart(..., on_select="rerun"), available since Streamlit | |
| 1.35) β no extra third-party click-handling package needed, but it does | |
| require `plotly` in addition to `streamlit`: | |
| pip install streamlit plotly | |
| Usage: | |
| streamlit run streamlit_app.py -- --data-root datasets | |
| """ | |
| import argparse | |
| import sys | |
| from pathlib import Path | |
| import matplotlib.pyplot as plt | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| try: | |
| from .data.loaders.station_elevations import StationElevationsLoader | |
| from .data.loaders.ades import ADESLoader | |
| from .data.loaders.hydrometric import HydrometricLoader | |
| from .data.river_graph import build_basin_graph, _PALETTE | |
| from .data.river_line import interpolate_along_chain, chain_total_length_km, groundwater_at_point, RiverPoint | |
| from .data.river_centerline import ( | |
| load_centerline, snap_gauges_to_centerline, cumulative_distance_km, | |
| interpolate_by_fraction, elevation_at_km, resample_centerline_for_clicks, | |
| ) | |
| except ImportError: | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from src.data.loaders.station_elevations import StationElevationsLoader | |
| from src.data.loaders.ades import ADESLoader | |
| from src.data.loaders.hydrometric import HydrometricLoader | |
| from src.data.river_graph import build_basin_graph, _PALETTE | |
| from src.data.river_line import interpolate_along_chain, chain_total_length_km, groundwater_at_point, RiverPoint | |
| from src.data.river_centerline import ( | |
| load_centerline, snap_gauges_to_centerline, cumulative_distance_km, | |
| interpolate_by_fraction, elevation_at_km, resample_centerline_for_clicks, | |
| ) | |
| BASIN_NAMES = {0: "La Eure", 1: "La Risle"} | |
| BASIN_FILE_NAMES = {0: "eure", 1: "risle"} | |
| N_CLICK_TARGETS = 300 # density of clickable points along the line | |
| def parse_data_root() -> Path: | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--data-root", type=Path, default=Path("datasets")) | |
| args, _ = parser.parse_known_args() | |
| return args.data_root | |
| def load_graph(data_root: str): | |
| elev_df = StationElevationsLoader(data_path=Path(data_root) / "station_elevations.csv").load() | |
| return build_basin_graph(elev_df) | |
| def load_centerlines(data_root: str, _nodes: pd.DataFrame): | |
| result = {} | |
| centerline_dir = Path(data_root) / "centerlines" | |
| for basin_id, file_key in BASIN_FILE_NAMES.items(): | |
| csv_path = centerline_dir / f"{file_key}_centerline.csv" | |
| if not csv_path.exists(): | |
| continue | |
| cl = load_centerline(csv_path) | |
| b_nodes = _nodes[_nodes["basin_id"] == basin_id] | |
| gauges = snap_gauges_to_centerline(cl, b_nodes) | |
| result[basin_id] = { | |
| "centerline": cl, | |
| "gauges": gauges, | |
| "total_km": float(cumulative_distance_km(cl)[-1]), | |
| "click_targets": resample_centerline_for_clicks(cl, gauges, n_points=N_CLICK_TARGETS), | |
| } | |
| return result | |
| def schematic_click_targets(_nodes, _edges, basin_id: int, n_points: int = N_CLICK_TARGETS): | |
| """Densely resample the schematic (straight-chain) fallback for click targets.""" | |
| rows = [] | |
| for i in range(n_points): | |
| frac = i / (n_points - 1) | |
| p = interpolate_along_chain(_nodes, _edges, basin_id, frac) | |
| rows.append({ | |
| "longitude": p.longitude, "latitude": p.latitude, | |
| "elevation_m": p.elevation_m, "fraction": frac, | |
| "upstream_station": p.upstream_station, "downstream_station": p.downstream_station, | |
| }) | |
| return pd.DataFrame(rows) | |
| def load_reach_graph(data_root: str, basin_id: int): | |
| """Loads the reach-based graph (real confluences + virtual nodes, | |
| from scripts/build_reach_graphs.py) for one basin, or None if it | |
| hasn't been built yet.""" | |
| file_key = BASIN_FILE_NAMES[basin_id] | |
| graph_dir = Path(data_root) / "reach_graph" | |
| nodes_path = graph_dir / f"{file_key}_nodes.csv" | |
| edges_path = graph_dir / f"{file_key}_edges.csv" | |
| if not nodes_path.exists() or not edges_path.exists(): | |
| return None | |
| # mtime is part of the cache key (see the wrapper below) specifically | |
| # so that re-running scripts/build_reach_graphs.py and overwriting | |
| # these files is picked up automatically -- st.cache_data otherwise | |
| # keys purely on this function's arguments, not file contents, so a | |
| # stale cached graph could silently keep being shown after a rebuild | |
| # (confirmed: this caused a real mismatch between numbers shown here | |
| # and scripts/diagnose_confluences.py reading the same, but freshly | |
| # re-read, file). | |
| return pd.read_csv(nodes_path), pd.read_csv(edges_path) | |
| def _reach_graph_mtime(data_root: str, basin_id: int) -> float: | |
| file_key = BASIN_FILE_NAMES[basin_id] | |
| graph_dir = Path(data_root) / "reach_graph" | |
| nodes_path = graph_dir / f"{file_key}_nodes.csv" | |
| edges_path = graph_dir / f"{file_key}_edges.csv" | |
| if not nodes_path.exists() or not edges_path.exists(): | |
| return 0.0 | |
| return max(nodes_path.stat().st_mtime, edges_path.stat().st_mtime) | |
| def load_reach_graph_cached(data_root: str, basin_id: int, _mtime: float): | |
| """Cache-safe wrapper: `_mtime` (leading underscore so Streamlit | |
| doesn't try to hash the float oddly, it's just part of the key) makes | |
| the cache key change whenever the underlying CSVs are rewritten, so a | |
| re-run of scripts/build_reach_graphs.py is picked up on the next | |
| Streamlit interaction without needing a manual restart or cache | |
| clear.""" | |
| return load_reach_graph(data_root, basin_id) | |
| def build_figure_reach_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, basin_name: str, color: str) -> go.Figure: | |
| """ | |
| Render the full reach graph -- potentially thousands of edges -- as | |
| ONE line trace using None-separated segments, rather than one Plotly | |
| trace per edge. A trace-per-edge approach is fine at the ~10-edge | |
| scale of the old single-chain graph but doesn't hold up at the | |
| 5,000+ edges this graph actually has; this stays fast regardless of | |
| graph size since Plotly natively supports gaps via None values in a | |
| single trace's coordinate arrays. | |
| Confluences (real nodes with more than one inflow) and real gauges | |
| are drawn as distinct marker layers on top of the line for visual | |
| validation -- confluences should visibly sit where tributaries | |
| join, and gauges should sit ON the network, not floating near it. | |
| Virtual (infill) nodes are deliberately NOT drawn as individual | |
| markers: at the density this graph uses them, thousands of extra | |
| markers would obscure the actual validation signal rather than | |
| help it -- they're implicitly represented by the line itself. | |
| """ | |
| lon_edges, lat_edges = [], [] | |
| node_lookup = nodes_df.set_index("station_code")[["latitude", "longitude"]] | |
| for _, e in edges_df.iterrows(): | |
| try: | |
| src, tgt = node_lookup.loc[e["source"]], node_lookup.loc[e["target"]] | |
| except KeyError: | |
| continue # an edge whose endpoint isn't in nodes_df -- shouldn't happen, skip rather than crash | |
| lon_edges += [src["longitude"], tgt["longitude"], None] | |
| lat_edges += [src["latitude"], tgt["latitude"], None] | |
| if "is_confluence" in nodes_df.columns: | |
| # Authoritative: computed once in build_reach_graph_tables (has full | |
| # graph topology, including the split-rejoin exclusion -- see | |
| # build_reach_graph.py's find_real_confluences/is_split_rejoin). | |
| # Re-deriving this from the flat edges table can't apply that | |
| # exclusion at all, and duplicating the name-only logic here | |
| # previously drifted out of sync with the graph-level count. | |
| confluences = nodes_df[nodes_df["is_confluence"]] | |
| elif "toponym" in edges_df.columns: | |
| name_counts = edges_df.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique() | |
| confluence_codes = name_counts[name_counts > 1].index | |
| confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)] | |
| else: | |
| # Oldest saved edges.csv, no toponym or is_confluence column at all -- | |
| # fall back to the raw (least reliable) in-degree rule. | |
| confluence_codes = edges_df["target"].value_counts() | |
| confluence_codes = confluence_codes[confluence_codes >= 2].index | |
| confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)] | |
| gauges = nodes_df[nodes_df["is_gauged"]] | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=lon_edges, y=lat_edges, mode="lines", | |
| line=dict(color=color, width=1), hoverinfo="skip", showlegend=False, | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=confluences["longitude"], y=confluences["latitude"], mode="markers", | |
| marker=dict(symbol="diamond", size=7, color="#8A8F87", | |
| line=dict(width=0.5, color="#5B6B63")), | |
| customdata=confluences["station_code"], | |
| hovertemplate="confluence %{customdata}<extra></extra>", | |
| name=f"confluences (n={len(confluences)})", showlegend=True, | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=gauges["longitude"], y=gauges["latitude"], mode="markers+text", | |
| marker=dict(symbol="circle", size=11, color=gauges["elevation_m"], colorscale="earth", | |
| line=dict(width=1, color="black"), showscale=True, | |
| colorbar=dict(title="Elev (m)", thickness=12)), | |
| text=gauges["station_code"], textposition="top right", textfont=dict(size=8), | |
| customdata=gauges["station_code"], | |
| hovertemplate="%{customdata}<extra></extra>", | |
| name=f"gauges (n={len(gauges)})", showlegend=True, | |
| )) | |
| fig.update_layout( | |
| title=f"{basin_name} β reach graph ({len(nodes_df)} nodes, {len(edges_df)} edges)", | |
| xaxis_title="Longitude", yaxis_title="Latitude", | |
| yaxis=dict(scaleanchor="x", scaleratio=1), | |
| height=650, margin=dict(l=10, r=10, t=40, b=10), | |
| legend=dict(orientation="h", y=-0.08), | |
| ) | |
| return fig | |
| def load_groundwater(data_root: str): | |
| ades_dir = Path(data_root) / "ades" | |
| if not ades_dir.exists(): | |
| return None | |
| try: | |
| df = ADESLoader(data_path=ades_dir).load() | |
| if {"lat", "lon", "groundwater_level_m"}.issubset(df.columns): | |
| return df.sort_values("date").drop_duplicates("code_bss", keep="last") | |
| except Exception as e: | |
| st.warning(f"Could not load groundwater data: {e}") | |
| return None | |
| def load_hydrometric(data_root: str): | |
| """Returns (loader, df) so plot methods can be called on the loader | |
| directly (they already know how to filter/style), or None if no | |
| hydrometric data is present under data_root/hydrometric.""" | |
| hydro_dir = Path(data_root) / "hydrometric" | |
| if not hydro_dir.exists(): | |
| return None | |
| try: | |
| loader = HydrometricLoader(data_path=hydro_dir) | |
| df = loader.load() | |
| return loader, df | |
| except FileNotFoundError as e: | |
| st.warning(f"Could not load hydrometric data: {e}") | |
| return None | |
| def nearest_gauge_real(info, distance_from_mouth_km: float): | |
| """Nearest gauge (by along-centerline distance) to a resolved position, | |
| for the real-centerline branch. Returns (station_code, distance_km).""" | |
| gauges = info["gauges"] | |
| diffs = (gauges["centerline_km"] - distance_from_mouth_km).abs() | |
| idx = diffs.idxmin() | |
| return gauges.loc[idx, "station_code"], float(diffs.loc[idx]) | |
| def nearest_gauge_schematic(upstream_station: str, downstream_station: str, fraction: float, edge_length_km: float): | |
| """Nearest gauge for the schematic (straight-chain) branch: whichever | |
| endpoint of the current edge the fraction is closer to.""" | |
| if fraction <= 0.5: | |
| return upstream_station, fraction * edge_length_km | |
| return downstream_station, (1 - fraction) * edge_length_km | |
| def render_station_plots(loader: HydrometricLoader, df: pd.DataFrame, station_code: str, distance_km: float): | |
| """Waterlevel, discharge, and rating-curve plots for one gauge, reusing | |
| HydrometricLoader's own matplotlib plot methods rather than | |
| reimplementing plotting logic here.""" | |
| st.subheader(f"Time series at nearest gauge: {station_code}") | |
| st.caption(f"{distance_km:.1f} km from the clicked/selected position along the river.") | |
| station_df = df[df["station_code"] == station_code] | |
| has_discharge = station_df["discharge_m3s"].notna().any() if "discharge_m3s" in station_df.columns else False | |
| has_waterlevel = station_df["waterlevel_mm"].notna().any() if "waterlevel_mm" in station_df.columns else False | |
| if not has_discharge and not has_waterlevel: | |
| st.info(f"No discharge or water-level observations for {station_code}.") | |
| return | |
| tab_labels = [] | |
| if has_waterlevel: | |
| tab_labels.append("Water level") | |
| if has_discharge: | |
| tab_labels.append("Discharge") | |
| if has_discharge and has_waterlevel: | |
| tab_labels.append("Rating curve") | |
| tabs = st.tabs(tab_labels) | |
| tab_iter = iter(tabs) | |
| if has_waterlevel: | |
| with next(tab_iter): | |
| ax = loader.plot_waterlevel(df=station_df, stations=[station_code]) | |
| st.pyplot(ax.figure) | |
| if has_discharge: | |
| with next(tab_iter): | |
| ax = loader.plot_discharge(df=station_df, stations=[station_code]) | |
| st.pyplot(ax.figure) | |
| if has_discharge and has_waterlevel: | |
| with next(tab_iter): | |
| try: | |
| ax = loader.plot_rating_curve(station_code, df=station_df) | |
| st.pyplot(ax.figure) | |
| except ValueError as e: | |
| st.info(f"Rating curve unavailable: {e}") | |
| plt.close("all") # avoid accumulating open figures across reruns | |
| def build_figure_real(info, basin_name: str, color: str, wells_df=None, marker_lon=None, marker_lat=None) -> go.Figure: | |
| cl, gauges, targets = info["centerline"], info["gauges"], info["click_targets"] | |
| fig = go.Figure() | |
| # Visible line (not a click target: no markers, so it can't be selected). | |
| fig.add_trace(go.Scatter( | |
| x=cl["longitude"], y=cl["latitude"], mode="lines", | |
| line=dict(color=color, width=3), hoverinfo="skip", showlegend=False, | |
| )) | |
| # Dense invisible-ish click targets, carrying (km, elevation) as customdata. | |
| fig.add_trace(go.Scatter( | |
| x=targets["longitude"], y=targets["latitude"], mode="markers", | |
| marker=dict(size=10, color=color, opacity=0.01), # near-invisible but clickable | |
| customdata=targets[["distance_from_mouth_km", "elevation_m"]].values, | |
| hovertemplate="%{customdata[0]:.1f} km from mouth<br>elev %{customdata[1]:.0f} m<extra></extra>", | |
| showlegend=False, name="river", | |
| )) | |
| # Real ADES well locations, so coverage gaps are visible at a glance | |
| # instead of only showing up as a blank groundwater estimate. | |
| if wells_df is not None and not wells_df.empty: | |
| fig.add_trace(go.Scatter( | |
| x=wells_df["lon"], y=wells_df["lat"], mode="markers", | |
| marker=dict(size=6, color="#8A8F87", opacity=0.55, | |
| line=dict(width=0.5, color="#5B6B63")), | |
| customdata=wells_df[["code_bss", "groundwater_level_m"]].values, | |
| hovertemplate="well %{customdata[0]}<br>%{customdata[1]:.1f} m<extra></extra>", | |
| showlegend=True, name=f"ADES wells (n={len(wells_df)})", | |
| )) | |
| # Gauge markers, colored by elevation, also clickable (own customdata). | |
| fig.add_trace(go.Scatter( | |
| x=gauges["centerline_lon"], y=gauges["centerline_lat"], mode="markers+text", | |
| marker=dict(size=12, color=gauges["elevation_m"], colorscale="earth", | |
| line=dict(width=1, color="black"), showscale=True, | |
| colorbar=dict(title="Elev (m)", thickness=12)), | |
| text=gauges["station_code"], textposition="top right", textfont=dict(size=9), | |
| customdata=gauges[["centerline_km", "elevation_m"]].values, | |
| hovertemplate="%{text}<br>%{customdata[0]:.1f} km<br>elev %{customdata[1]:.0f} m<extra></extra>", | |
| showlegend=False, name="gauges", | |
| )) | |
| if marker_lon is not None: | |
| fig.add_trace(go.Scatter( | |
| x=[marker_lon], y=[marker_lat], mode="markers", | |
| marker=dict(size=16, color="#C98A28", line=dict(width=2, color="white")), | |
| hoverinfo="skip", showlegend=False, | |
| )) | |
| fig.update_layout( | |
| title=f"{basin_name} β click the line", | |
| xaxis_title="Longitude", yaxis_title="Latitude", | |
| yaxis=dict(scaleanchor="x", scaleratio=1), | |
| height=520, margin=dict(l=10, r=10, t=40, b=10), | |
| clickmode="event+select", legend=dict(orientation="h", y=-0.12), | |
| ) | |
| return fig | |
| def build_figure_schematic(targets, basin_name: str, color: str, marker_x=None, marker_y=None) -> go.Figure: | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=list(range(len(targets))), y=targets["elevation_m"], mode="lines", | |
| line=dict(color=color, width=3), hoverinfo="skip", showlegend=False, | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=list(range(len(targets))), y=targets["elevation_m"], mode="markers", | |
| marker=dict(size=10, color=color, opacity=0.01), | |
| customdata=targets[["fraction", "elevation_m"]].values, | |
| hovertemplate="%{customdata[1]:.0f} m elevation<extra></extra>", | |
| showlegend=False, | |
| )) | |
| if marker_x is not None: | |
| fig.add_trace(go.Scatter( | |
| x=[marker_x], y=[marker_y], mode="markers", | |
| marker=dict(size=16, color="#C98A28", line=dict(width=2, color="white")), | |
| hoverinfo="skip", showlegend=False, | |
| )) | |
| fig.update_layout( | |
| title=f"{basin_name} β click the line (schematic, no digitized centerline)", | |
| xaxis_title="Position along river (upstream β downstream)", yaxis_title="Elevation (m)", | |
| xaxis=dict(showticklabels=False), height=340, | |
| margin=dict(l=10, r=10, t=40, b=10), clickmode="event+select", | |
| ) | |
| return fig | |
| def main(): | |
| st.set_page_config(page_title="River Network Explorer", layout="centered") | |
| data_root = parse_data_root() | |
| st.title("River Network Explorer") | |
| view = st.radio("View", options=["Explore", "Network validation"], horizontal=True) | |
| basin_id = st.radio("River", options=list(BASIN_NAMES.keys()), | |
| format_func=lambda b: BASIN_NAMES[b], horizontal=True) | |
| basin_name = BASIN_NAMES[basin_id] | |
| color = _PALETTE[basin_id % len(_PALETTE)] | |
| if view == "Network validation": | |
| mtime = _reach_graph_mtime(str(data_root), basin_id) | |
| reach = load_reach_graph_cached(str(data_root), basin_id, mtime) | |
| if reach is None: | |
| st.info( | |
| f"No reach graph found for {basin_name} under " | |
| f"`{data_root}/reach_graph/`. Run `python -m scripts.build_reach_graphs` " | |
| f"first." | |
| ) | |
| st.stop() | |
| reach_nodes, reach_edges = reach | |
| has_is_confluence = "is_confluence" in reach_nodes.columns | |
| has_toponym = "toponym" in reach_edges.columns | |
| if has_is_confluence: | |
| n_confluences = int(reach_nodes["is_confluence"].sum()) | |
| elif has_toponym: | |
| name_counts = reach_edges.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique() | |
| n_confluences = int((name_counts > 1).sum()) | |
| else: | |
| n_confluences = int((reach_edges["target"].value_counts() >= 2).sum()) | |
| n_gauged = int(reach_nodes["is_gauged"].sum()) | |
| n_virtual = len(reach_nodes) - n_gauged - n_confluences | |
| col1, col2, col3, col4 = st.columns(4) | |
| col1.metric("Nodes", len(reach_nodes)) | |
| col2.metric("Edges", len(reach_edges)) | |
| col3.metric("Confluences", n_confluences) | |
| col4.metric("Gauges", n_gauged) | |
| if has_is_confluence: | |
| st.caption( | |
| f"Confluence = a genuinely different named river joining, with an " | |
| f"independent upstream source β excludes both same-river tronΓ§on " | |
| f"segmentation AND a channel that splits and rejoins downstream " | |
| f"(braiding/anabranches carry no new mass, even if named differently). " | |
| f"{n_virtual} virtual/infill nodes are not individually marked below." | |
| ) | |
| elif has_toponym: | |
| st.caption( | |
| "β οΈ This graph predates the split-rejoin fix β the count above uses " | |
| "name-only matching, which can still misclassify a braided channel " | |
| "that splits and rejoins as a real confluence. Re-run " | |
| "`python -m scripts.build_reach_graphs` to get the corrected count." | |
| ) | |
| else: | |
| st.caption( | |
| "β οΈ This graph was built before the name-based confluence fix β the count " | |
| "above uses the old, least reliable 'any node with 2+ incoming edges' rule, " | |
| "which BD TOPO's fine tronΓ§on segmentation inflates well above the real " | |
| "number of tributary junctions. Re-run `python -m scripts.build_reach_graphs` " | |
| "to get the corrected count." | |
| ) | |
| st.plotly_chart(build_figure_reach_graph(reach_nodes, reach_edges, basin_name, color), | |
| width="stretch") | |
| max_snap = None | |
| if "snap_distance_km" in reach_nodes.columns: | |
| max_snap = reach_nodes["snap_distance_km"].max() | |
| st.caption( | |
| "Confirm visually: confluences (β) should sit where a tributary " | |
| "visibly joins the main line, not floating off it or stacked on " | |
| "top of another confluence. Gauges (β) should sit directly on the " | |
| "network, not offset from it." | |
| + (f" Max real gauge snap distance in this basin: {max_snap:.3f} km." | |
| if max_snap is not None else "") | |
| ) | |
| return | |
| st.caption("Click directly on the line (or use the slider) to read elevation and estimated groundwater level.") | |
| try: | |
| nodes, edges = load_graph(str(data_root)) | |
| except FileNotFoundError: | |
| st.error(f"Could not find station_elevations.csv under {data_root}") | |
| st.stop() | |
| centerlines = load_centerlines(str(data_root), nodes) | |
| groundwater_df = load_groundwater(str(data_root)) | |
| if groundwater_df is not None: | |
| st.caption(f"Loaded {groundwater_df['code_bss'].nunique()} groundwater wells (ADES).") | |
| else: | |
| st.caption("No groundwater well data found β groundwater estimates will be unavailable.") | |
| has_centerline = basin_id in centerlines | |
| click_key = f"click_{basin_id}" | |
| slider_key = f"slider_{basin_id}" | |
| last_click_key = f"last_click_{basin_id}" | |
| if slider_key not in st.session_state: | |
| st.session_state[slider_key] = 50 # start at 50% | |
| if has_centerline: | |
| info = centerlines[basin_id] | |
| fig = build_figure_real(info, basin_name, color, wells_df=groundwater_df) | |
| event = st.plotly_chart(fig, on_select="rerun", selection_mode=["points"], key=click_key) | |
| points = (event or {}).get("selection", {}).get("points", []) | |
| if points: | |
| click_signature = tuple(points[-1]["customdata"]) | |
| # Only act on a NEW click: Streamlit's selection state persists across | |
| # reruns (e.g. when the user then moves the slider), so without this | |
| # guard every later rerun would silently snap the slider back to the | |
| # old click position and the slider would appear "stuck". | |
| if st.session_state.get(last_click_key) != click_signature: | |
| st.session_state[last_click_key] = click_signature | |
| km, _elev = click_signature | |
| st.session_state[slider_key] = int(round(100 * float(km) / info["total_km"])) | |
| percent = st.slider( | |
| f"Position along {basin_name} β mouth β source ({info['total_km']:.0f} km real course)", | |
| min_value=0, max_value=100, format="%d%%", key=slider_key, | |
| ) | |
| fraction = percent / 100.0 | |
| cp = interpolate_by_fraction(info["centerline"], fraction) | |
| elev = elevation_at_km(info["gauges"], cp.distance_from_mouth_km) | |
| lat, lon = cp.latitude, cp.longitude | |
| distance_label = f"{cp.distance_from_mouth_km:.2f} km" | |
| distance_sub = f"from mouth, of {cp.total_length_km:.0f} km total" | |
| nearest_station, nearest_station_km = nearest_gauge_real(info, cp.distance_from_mouth_km) | |
| # Redraw with the marker at the resolved position (click or slider). | |
| fig2 = build_figure_real(info, basin_name, color, wells_df=groundwater_df, marker_lon=lon, marker_lat=lat) | |
| st.plotly_chart(fig2, width="stretch", key=f"{click_key}_marked") | |
| else: | |
| st.info(f"No digitized centerline found for {basin_name} β showing schematic view.") | |
| targets = schematic_click_targets(nodes, edges, basin_id) | |
| fig = build_figure_schematic(targets, basin_name, color) | |
| event = st.plotly_chart(fig, on_select="rerun", selection_mode=["points"], key=click_key) | |
| points = (event or {}).get("selection", {}).get("points", []) | |
| if points: | |
| click_signature = tuple(points[-1]["customdata"]) | |
| if st.session_state.get(last_click_key) != click_signature: | |
| st.session_state[last_click_key] = click_signature | |
| frac, _elev = click_signature | |
| st.session_state[slider_key] = int(round(100 * float(frac))) | |
| percent = st.slider( | |
| f"Position along {basin_name} (straight-line schematic)", | |
| min_value=0, max_value=100, format="%d%%", key=slider_key, | |
| ) | |
| fraction = percent / 100.0 | |
| point = interpolate_along_chain(nodes, edges, basin_id, fraction) | |
| elev = point.elevation_m | |
| lat, lon = point.latitude, point.longitude | |
| distance_label = f"{point.distance_from_upstream_km:.2f} km" | |
| distance_sub = f"of {point.edge_length_km:.2f} km segment" | |
| nearest_station, nearest_station_km = nearest_gauge_schematic( | |
| point.upstream_station, point.downstream_station, point.fraction, point.edge_length_km | |
| ) | |
| x_pos = fraction * (len(targets) - 1) | |
| fig2 = build_figure_schematic(targets, basin_name, color, marker_x=x_pos, marker_y=elev) | |
| st.plotly_chart(fig2, width="stretch", key=f"{click_key}_marked") | |
| gwl, nearest_well_km = None, None | |
| if groundwater_df is not None and {"lat", "lon", "groundwater_level_m"}.issubset(groundwater_df.columns): | |
| pseudo_point = RiverPoint(basin_id=basin_id, upstream_station="", downstream_station="", | |
| fraction=0, latitude=lat, longitude=lon, elevation_m=elev or 0, | |
| distance_from_upstream_km=0, edge_length_km=0) | |
| gwl, nearest_well_km = groundwater_at_point(pseudo_point, groundwater_df) | |
| col1, col2, col3 = st.columns(3) | |
| col1.metric("Elevation", f"{elev:.1f} m" if elev is not None else "β") | |
| col2.metric("Distance", distance_label, distance_sub) | |
| if gwl is not None: | |
| col3.metric("Est. groundwater level", f"{gwl:.1f} m") | |
| elif nearest_well_km is not None and nearest_well_km != float("inf"): | |
| col3.metric("Est. groundwater level", "β", f"nearest well is {nearest_well_km:.0f} km away") | |
| else: | |
| col3.metric("Est. groundwater level", "β", "no well data loaded") | |
| st.caption(f"Coordinates: {lat:.4f}, {lon:.4f}") | |
| st.divider() | |
| hydro = load_hydrometric(str(data_root)) | |
| if hydro is None: | |
| st.info("No hydrometric data found β station plots unavailable.") | |
| else: | |
| hydro_loader, hydro_df = hydro | |
| render_station_plots(hydro_loader, hydro_df, nearest_station, nearest_station_km) | |
| if __name__ == "__main__": | |
| main() |