Commit 082fb1d9 authored by Paul Rougieux's avatar Paul Rougieux
Browse files

Merge opus road closure page and maps into one self-contained html

parent 822885c7
Loading
Loading
Loading
Loading
Loading
+80 −41
Changes for events/20261021/impl_opus/05_page.py: 80 added lines, 41 removed lines.
Original line number Diff line number Diff line
@@ -2,9 +2,10 @@

The page covers every bridge preset that has results (run 01-04 for each, e.g.
BRIDGE=gelbingen). Buttons at the top switch between them; the data of each
bridge is embedded as JSON, and each bridge has its own Folium map.
bridge (figures and map layers) is embedded as JSON; one Leaflet map shows
the layer group of the active bridge.

Output: output/index.html and output/map_<bridge>.html
Output: output/index.html, one self-contained file (Leaflet and the OSM tiles load from the web)
"""

import html
@@ -13,7 +14,6 @@ import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import folium
import networkx as nx
import osmnx as ox
import pandas as pd
@@ -34,11 +34,23 @@ def eur(x):
    return f"€{fmt(x)}"


def route_coords(G, path):
    return [(G.nodes[n]["y"], G.nodes[n]["x"]) for n in path]
def route_coords(G, path, weight="travel_time"):
    """[lat, lon] list along a node path, using the edge geometry where OSMnx kept one."""
    pts = [[G.nodes[path[0]]["y"], G.nodes[path[0]]["x"]]]
    for a, b in zip(path[:-1], path[1:]):
        e = min(G[a][b].values(), key=lambda d: d[weight])
        if "geometry" in e:
            xy = list(e["geometry"].coords)
            if (round(xy[0][0], 6), round(xy[0][1], 6)) != (round(G.nodes[a]["x"], 6), round(G.nodes[a]["y"], 6)):
                xy = xy[::-1]
            pts.extend([y, x] for x, y in xy[1:])
        else:
            pts.append([G.nodes[b]["y"], G.nodes[b]["x"]])
    return [[round(la, 5), round(lo, 5)] for la, lo in pts]


def build_map(cfg, res, out, map_path):
def build_geo(cfg, res, out):
    """Map data of one bridge, embedded in the page as JSON and drawn with Leaflet."""
    b = cfg["bridge_info"]
    st = res["counts"]["station"]
    G = ox.load_graphml(out / "graph.graphml", edge_dtypes={"travel_time": float, "length": float,
@@ -46,38 +58,24 @@ def build_map(cfg, res, out, map_path):
    bridge_edges = json.loads((out / "bridge_edges.json").read_text())
    G1 = G.copy()
    G1.remove_edges_from([(e["u"], e["v"], e["k"]) for e in bridge_edges])

    m = folium.Map(location=[b["lat"], b["lon"]], zoom_start=10, tiles="OpenStreetMap")
    folium.Circle([b["lat"], b["lon"]], radius=cfg["network"]["buffer_km"] * 1000, color="#898781",
                  weight=1, fill=False, dash_array="4 6", tooltip=f"OD buffer {cfg['network']['buffer_km']} km").add_to(m)

    od = pd.read_csv(out / "od_points.csv")
    od = od[od["dist_bridge_km"] <= cfg["network"]["buffer_km"]]
    for _, r in od.iterrows():
        folium.CircleMarker([r["lat"], r["lon"]], radius=max(2, (r["pop"] ** 0.5) / 25), color="#52514e",
                            weight=1, fill=True, fill_opacity=0.3,
                            tooltip=f"{r['name']}: {fmt(r['pop'])} inhabitants").add_to(m)

    routes_with = folium.FeatureGroup(name="Route with the bridge (blue)")
    routes_without = folium.FeatureGroup(name="Detour without the bridge (orange, dashed)")
    routes = []
    for p in res["top_pairs_pkw"][:N_ROUTES]:
        tip = (f"{p['o_name']} → {p['d_name']}: {fmt(p['veh'])} Pkw/day, "
               f"{p['t0_s'] / 60:.0f} → {p['t1_s'] / 60:.0f} min, {p['d0_m'] / 1000:.1f} → {p['d1_m'] / 1000:.1f} km")
        p0 = nx.shortest_path(G, p["o_node"], p["d_node"], weight="travel_time")
        p1 = nx.shortest_path(G1, p["o_node"], p["d_node"], weight="travel_time")
        folium.PolyLine(route_coords(G, p0), color=WITH_COLOR, weight=3, opacity=0.8, tooltip=tip).add_to(routes_with)
        folium.PolyLine(route_coords(G1, p1), color=WITHOUT_COLOR, weight=3, opacity=0.8,
                        dash_array="6 6", tooltip=tip).add_to(routes_without)
    routes_with.add_to(m)
    routes_without.add_to(m)

    folium.Marker([b["lat"], b["lon"]], tooltip=f"Closed: {b['name']}",
                  icon=folium.Icon(color="black", icon="remove")).add_to(m)
    folium.Marker([st["lat"], st["lon"]],
                  tooltip=f"BASt station {st['tknr']} {st['name']} ({st['road']}), DTV 2023 {fmt(st['dtv_kfz_bast'])}",
                  icon=folium.Icon(color="blue", icon="stats")).add_to(m)
    folium.LayerControl(collapsed=False).add_to(m)
    m.save(map_path)
        routes.append({"tip": tip, "with": route_coords(G, p0), "without": route_coords(G1, p1)})
    return {
        "bridge": [round(b["lat"], 5), round(b["lon"], 5)],
        "bridge_tip": f"Closed: {b['name']}",
        "station": [round(st["lat"], 5), round(st["lon"], 5)],
        "station_tip": f"BASt station {st['tknr']} {st['name']} ({st['road']}), DTV 2023 {fmt(st['dtv_kfz_bast'])}",
        "buffer_m": cfg["network"]["buffer_km"] * 1000,
        "places": [[round(r.lat, 5), round(r.lon, 5), r.name, int(r.pop)] for r in od.itertuples()],
        "routes": routes,
    }


PAGE = """<!doctype html>
@@ -86,6 +84,8 @@ PAGE = """<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>B19 Bridge Closure Cost</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
:root {
  --bg: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink2: #52514e; --muted: #898781;
@@ -124,6 +124,8 @@ h2 { font-size: 1.15rem; margin: 32px 0 8px; }
.warn { background: var(--warn-bg); color: var(--warn-ink); border-radius: 8px; padding: 10px 14px; margin: 12px 0 0; }
.warn[hidden] { display: none; }
.map { width: 100%; height: 520px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
.map .leaflet-tooltip { font-size: .8rem; }
.mapnote { padding: 16px; color: var(--muted); }
.legend { color: var(--ink2); font-size: .85rem; margin-top: 6px; }
.sw { display: inline-block; width: 22px; height: 0; border-top: 3px solid; vertical-align: middle; margin-right: 4px; }
.tablewrap { overflow-x: auto; }
@@ -158,10 +160,11 @@ OD buffer <span id="buffer"></span> km.</p>
<p class="muted" id="extra-line"></p>

<h2>Map</h2>
<iframe class="map" id="map" title="Map of the bridge, the count station and the main OD routes"></iframe>
<div class="map" id="map" role="region" aria-label="Map of the bridge, the count station and the main OD routes"></div>
<div class="legend"><span class="sw" style="border-color:__C_WITH__"></span>route with the bridge &nbsp;
<span class="sw" style="border-color:__C_WITHOUT__;border-top-style:dashed"></span>detour without the bridge &nbsp;
(the __N_ROUTES__ OD pairs with the most Pkw; grey circles: municipalities, sized by population)</div>
(the __N_ROUTES__ OD pairs with the most Pkw). Black dot: the closed bridge; blue dot: BASt count station;
grey circles: municipalities, sized by population.</div>

<h2>Sensitivity</h2>
<div class="tablewrap"><table><thead><tr><th>Buffer</th><th>Value of time</th><th>Extra veh-h/day</th>
@@ -261,8 +264,7 @@ Destatis Gemeindeverzeichnis, Methodenhandbuch BVWP 2030, Land Baden-Württember
        " km whose fastest route uses the bridge; " + fmt(d.n_pairs.pkw) +
        " ordered pairs for Pkw. Gravity weight w = pop_i · pop_j / d_ij².");

    var frame = document.getElementById("map");
    if (frame.getAttribute("src") !== d.map) frame.setAttribute("src", d.map);
    showMap(key);

    document.querySelectorAll(".switch button").forEach(function (btn) {
      btn.setAttribute("aria-pressed", btn.dataset.bridge === key ? "true" : "false");
@@ -272,6 +274,45 @@ Destatis Gemeindeverzeichnis, Methodenhandbuch BVWP 2030, Land Baden-Württember
    });
  }

  // One Leaflet map, one layer group per bridge (built on first use), only the active one shown
  var map = null, groups = {};
  function buildGroup(g) {
    var grp = L.layerGroup();
    L.circle(g.bridge, {radius: g.buffer_m, color: "#898781", weight: 1, fill: false, dashArray: "4 6"})
      .bindTooltip("OD buffer " + g.buffer_m / 1000 + " km").addTo(grp);
    g.places.forEach(function (p) {
      L.circleMarker([p[0], p[1]], {radius: Math.max(2, Math.sqrt(p[3]) / 25), color: "#52514e", weight: 1,
        fillOpacity: 0.3}).bindTooltip(p[2] + ": " + fmt(p[3]) + " inhabitants").addTo(grp);
    });
    g.routes.forEach(function (r) {
      L.polyline(r["with"], {color: "__C_WITH__", weight: 3, opacity: 0.8}).bindTooltip(r.tip).addTo(grp);
      L.polyline(r["without"], {color: "__C_WITHOUT__", weight: 3, opacity: 0.8, dashArray: "6 6"})
        .bindTooltip(r.tip).addTo(grp);
    });
    L.circleMarker(g.station, {radius: 7, color: "#ffffff", weight: 2, fillColor: "#2a78d6", fillOpacity: 1})
      .bindTooltip(g.station_tip).addTo(grp);
    L.circleMarker(g.bridge, {radius: 9, color: "#ffffff", weight: 2, fillColor: "#0b0b0b", fillOpacity: 1})
      .bindTooltip(g.bridge_tip).addTo(grp);
    return grp;
  }
  function showMap(key) {
    var el = document.getElementById("map");
    if (typeof L === "undefined") {  // Leaflet could not be loaded (offline)
      el.innerHTML = '<p class="mapnote">The map needs the Leaflet library from cdnjs.cloudflare.com.</p>';
      return;
    }
    if (!map) {
      map = L.map(el);
      L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19,
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'}).addTo(map);
    }
    Object.keys(groups).forEach(function (k) { if (k !== key) map.removeLayer(groups[k]); });
    if (!groups[key]) groups[key] = buildGroup(DATA[key].geo);
    groups[key].addTo(map);
    var g = DATA[key].geo;
    map.fitBounds(L.latLng(g.bridge[0], g.bridge[1]).toBounds(2 * g.buffer_m), {padding: [10, 10]});
  }

  function fromHash() {
    var key = decodeURIComponent(location.hash.replace(/^#/, ""));
    return DATA[key] ? key : DEFAULT;
@@ -323,9 +364,8 @@ def main():
            continue
        res = json.loads((out / "results.json").read_text())
        sens = pd.read_csv(out / "sensitivity.csv")
        map_name = f"map_{key}.html"
        print(f"Building {map_name}")
        build_map(bcfg, res, out, OUTPUT / map_name)
        print(f"Building map data for {key}")
        geo = build_geo(bcfg, res, out)
        cases[key] = res
        counts = {k: res["counts"][k] for k in ("station", "days_used", "days_rule", "dtv_kfz_total",
                                                 "dtv_lkw_total", "dtv_source")}
@@ -334,7 +374,7 @@ def main():
            "options": res["options"], "counts": counts, "central": res["central"],
            "central_bpr": res.get("central_bpr"), "vehicles_not_assigned": res.get("vehicles_not_assigned", 0),
            "n_pairs": res["n_pairs"], "n_municipalities": res["n_municipalities"],
            "sensitivity": sens.to_dict(orient="records"), "map": map_name,
            "sensitivity": sens.to_dict(orient="records"), "geo": geo,
            "vot_simple": {c: " / ".join(str(v["simple"][c][k]) for k in ("low", "central", "high"))
                           for c in ("pkw", "lkw")},
            "price_factor": v["bvwp"]["price_factor"],
@@ -351,8 +391,7 @@ def main():
            .replace("__C_WITH__", WITH_COLOR).replace("__C_WITHOUT__", WITHOUT_COLOR)
            .replace("__N_ROUTES__", str(N_ROUTES)))
    (OUTPUT / "index.html").write_text(page, encoding="utf-8")
    old = OUTPUT / "map.html"  # single map of the previous page version
    if old.exists():
    for old in OUTPUT.glob("map*.html"):  # separate map files of earlier page versions
        old.unlink()
    print(f"Wrote {OUTPUT / 'index.html'} for {', '.join(data)}")

+10 −8
Changes for events/20261021/impl_opus/README.md: 10 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ step (`../traffic_jam_counts_to_time.md`) is included behind a config switch.

```bash
python3 -m venv .venv
.venv/bin/pip install duckdb pandas pyarrow osmnx networkx scikit-learn folium pyyaml openpyxl osmium
.venv/bin/pip install duckdb pandas pyarrow osmnx networkx scikit-learn pyyaml openpyxl osmium
.venv/bin/python -I 01_counts.py      # BASt station list + DTV at station 6724 (DuckDB stream, ~5 s)
.venv/bin/python -I 02_network_od.py  # OSM graph from Geofabrik extracts (~2 min after a ~1 GB download) + municipalities
.venv/bin/python -I 03_routing.py     # t0/t1, d0/d1 for all municipality pairs that cross the bridge
@@ -29,12 +29,14 @@ done
```

04 keeps `data/<bridge>/results.json` and `sensitivity.csv` per bridge (and copies the last run to
`output/`). 05 writes `output/index.html` with one button per bridge and one map per bridge
(`output/map_<bridge>.html`, swapped in the iframe). The data of each bridge is embedded in the page as
JSON, so the page is static: it works with `python3 -m http.server -d output` or GitLab Pages, and
offline apart from the OpenStreetMap tiles. The choice is kept in the URL hash (`index.html#gelbingen`);
the default is the `bridge:` from the config (oberkochen). Opening the page via `file://` can block the
map tiles; serve it over http.
`output/`). 05 writes one self-contained file, `output/index.html` (about 0.8 MB): one button per bridge,
and the figures and map layers of every bridge embedded as JSON. A single Leaflet map shows the layer group
of the active bridge (buffer circle, municipalities, the 8 main routes with and without the bridge, the
bridge and the count station) and zooms to it. Only Leaflet (cdnjs) and the OpenStreetMap tiles load from
the web; there are no fetch calls and no local iframes, so it works with `python3 -m http.server -d output`,
GitLab Pages, or as a single file. The choice is kept in the URL hash (`index.html#gelbingen`); the default
is the `bridge:` from the config (oberkochen). Opening the page via `file://` can block the map tiles;
serve it over http.

All parameters are in `config.yaml`: the bridge (`bridge:` picks a preset),
buffer sizes, default speeds, value of time method (`simple` or `bvwp`) and the
@@ -51,7 +53,7 @@ Downloads and intermediate files go to `data/` (git-ignored); results per bridge
| `03_routing.py` | networkx Dijkstra with and without the bridge, per vehicle class |
| `03b_bpr.py` | Optional hourly BPR volume-delay step on the detour links |
| `04_cost.py` | Gravity scaling of the station DTV over the pairs, cost per class, sensitivity |
| `05_page.py` | HTML page with bridge buttons, Folium maps, headline numbers, sensitivity and comparison tables |
| `05_page.py` | Self-contained HTML page: bridge buttons, Leaflet map, headline numbers, sensitivity and comparison tables |
| `common.py` | Config loading, paths, haversine, gravity scaling |

## Data sources
+48 −5

File changed.

Preview size limit exceeded, changes collapsed.

+0 −1498

File deleted.

Preview size limit exceeded, changes collapsed.

+0 −1402

File deleted.

Preview size limit exceeded, changes collapsed.

Loading