File size: 28,115 Bytes
a74054f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
"""
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


@st.cache_data
def load_graph(data_root: str):
    elev_df = StationElevationsLoader(data_path=Path(data_root) / "station_elevations.csv").load()
    return build_basin_graph(elev_df)


@st.cache_data
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


@st.cache_data
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)


@st.cache_data
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


@st.cache_data
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


@st.cache_data
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()