Folium-Karten: die Blockade war eine falsche Alternative

Der einzige Punkt, der in diesem Projekt je als "blockiert" gefuehrt wurde,
ist erledigt. Die Begruendung lautete: Die VRP-Instanz sei ein synthetisches
100 x 100-km-Raster ohne Geokoordinaten, eine echte Karte verlange also
ENTWEDER eine neue Instanz (und damit zwei Dutzend neue abgedruckte Zahlen)
ODER eine willkuerliche Verankerung.

Es gibt einen dritten Weg, und er ist exakt. Eine azimutal-aequidistante
Projektion um den Schwerpunkt des Rasters bildet die 17 Punkte so auf Laenge
und Breite ab, dass die Grosskreisdistanzen mit den Rasterdistanzen
uebereinstimmen:

  festes cos(Breite)        Median 0,360 %   Maximum 2,003 %
  cos der eigenen Breite    Median 0,351 %   Maximum 0,908 %
  azimutal-aequidistant     Median 0,000 %   Maximum 0,001 %

Ein Meter auf hundert Kilometer. Kein abgedruckter Wert aendert sich, die
Gesamtstrecke bleibt bei 619 km, das SVG ist byte-identisch geblieben.

Zwei Befunde stuetzten die Entscheidung: "100 x 100 km Raster" steht nur in
zwei Codekommentaren, nirgends im Fliesstext; und der didaktische Punkt der
Bildunterschrift - die sich kreuzenden Touren, die KEIN Fehler sind - wird auf
einer Karte deutlicher.

erzeuge_vrp_touren.py schreibt jetzt SVG UND Karte aus einem Lauf. Ein zweites
Skript haette erneut geloest, und die Routing-Bibliothek arbeitet heuristisch.
pruefe_projektion() bricht ab, wenn die Verzerrung 0,01 % ueberschreitet - die
Zahl im Buch wird bei jedem Lauf bewiesen.

Neu im Build: die Marke {karte:name}, aufgeloest zu einem <iframe> (Leaflets
Stylesheet soll nicht in die Buchseite greifen), im PDF zum Verweissatz - wie
bei {plotly:}. --check bewacht jetzt beide Markenarten auf Existenz der Datei;
gegengeprueft mit einer absichtlich falschen Marke.

Zwei Vorkehrungen, die ohne Messung nicht aufgefallen waeren:

* Folium vergibt jedem Element eine uuid4 - jeder Lauf erzeugte eine andere
  Datei. normalisiere() ersetzt sie durch fortlaufende Nummern; zwei Laeufe
  liefern jetzt dieselbe Pruefsumme.
* Folium bindet ZEHN CDN-Ressourcen ein (jQuery, Bootstrap, Glyphicons,
  FontAwesome, awesome-markers ...). Gebraucht werden ZWEI. leaflet.js und
  leaflet.css liegen jetzt in web_04/assets/, die uebrigen acht entfallen.
  Nachgeprueft: Die Karte benutzt ausschliesslich L.*-Aufrufe. Ohne Netz
  fehlen nur die Kacheln, Marker und Touren bleiben sichtbar.

Gegenrechnung an vier Strecken: Depot -> K1 misst auf der Karte 27,20 km, die
Distanzmatrix sagt 27 - die Differenz stammt aus deren int(), nicht aus der
Geographie.

Veroeffentlicht: 8 Dateien uebertragen, alle dreizehn HTTP-Proben richtig, die
Karte auf dem Server unter 200 erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 20:38:16 +02:00
commit 5d5bdb6508
19 changed files with 2521 additions and 5 deletions

View file

@ -493,6 +493,21 @@ sicheres Zeichen für eine verbesserbare Lösung — hier ist es keines: Die Zei
erzwingen die Reihenfolge, und wer die Kreuzungen auflöst, kommt zu spät. Erzeugt von
`bilder_04/erzeuge_vrp_touren.py`.](bilder_04/kap_graphen_vrp_touren.svg)
{karte:kap_graphen_vrp_touren}
**Was Sie auf der Karte sehen — und was nicht.** Die Instanz ist synthetisch: 17 zufällige
Punkte auf einem Quadrat von 100 × 100 km. Für die Karte wurden sie *lagegetreu* auf eine
reale Region gelegt — mit einer azimutal-äquidistanten Projektion, die alle 136 Abstände
zwischen den Punkten bis auf **0,001 %** erhält. Die Karte zeigt also dieselbe Instanz, mit
der das Modell rechnet, nur an geographischen Koordinaten statt auf einem Zahlenraster; kein
Wert in diesem Kapitel ändert sich dadurch.
Die Touren sind aus demselben Grund **Luftlinien und keine Straßenrouten**: Das Modell kennt
keine Straßen, es rechnet mit den Abständen der Distanzmatrix. Eine Linie, die einer echten
Straße folgte, wäre hübscher und würde eine Genauigkeit vortäuschen, die das Modell nicht
hat. Wer Straßenentfernungen braucht, ersetzt die Matrix — {ref:sec:graphen-denkfehler} sagt,
was dabei zu beachten ist.
### Kurzzyklen verhindern
Ein naives Modell erlaubt **Subtouren**{idx:Subtour}: isolierte Kreise, die das Depot nie anfahren. Die

View file

@ -390,6 +390,23 @@ def pruefe_dateien() -> list[str]:
f"folgt aber '{soll}' - die Lesekette ueberspringt eine Datei.")
fehlend.append(name)
# Marken fuer interaktive Figuren: Zeigt eine ins Leere, verschwindet sie
# auf der Website stillschweigend - im PDF steht dann ein Verweis auf eine
# Figur, die es nicht gibt.
for name in DATEIEN:
with open(os.path.join(HIER, name), encoding="utf-8") as f:
inhalt = f.read()
for marke, verzeichnis in ((PLOTLY_RE, PLOTLY_VERZ),
(KARTE_RE, KARTEN_VERZ)):
for treffer in marke.finditer(inhalt):
pfad = os.path.join(verzeichnis, f"{treffer.group(1)}.html")
if not os.path.exists(pfad):
zeile = inhalt[:treffer.start()].count("\n") + 1
print(f"FEHLER: {name}:{zeile}: '{treffer.group(0).strip()}' "
f"verweist auf eine nicht vorhandene Datei "
f"({os.path.relpath(pfad, BASIS)}).")
fehlend.append(f"{name}:{zeile}")
fehlend.extend(pruefe_assets())
fehlend.extend(pruefe_titeltexte())
return fehlend
@ -1088,6 +1105,7 @@ def baue_markdown() -> str:
gesamt = "\n\n---\n\n".join(teile) + "\n" + _STICHWORTREGISTER_BLOCK
gesamt, _ = resolve_plotly(gesamt, fuer_html=False)
gesamt = resolve_karte(gesamt, fuer_html=False)
return resolve_index(resolve_numbering(reflow_markdown(gesamt)))
@ -1865,6 +1883,45 @@ PLOTLY_RE = re.compile(r"^\{plotly:([\w-]+)\}[ \t]*$", re.MULTILINE)
PLOTLY_VERZ = os.path.join(BILDER_VERZ, "plotly")
# Dasselbe fuer Leaflet-Karten:
#
# {karte:kap_graphen_vrp_touren}
#
# Anders als die Plotly-Fragmente wird eine Karte NICHT eingebettet, sondern in
# einem <iframe> gezeigt. Zwei Gruende: Leaflets Stylesheet ist umfangreich und
# griffe sonst in die Buchseite hinein, und Foliums Ausgabe ist ein
# vollstaendiges HTML-Dokument, das sich so unveraendert uebernehmen laesst.
# Erzeugt werden die Karten von den bilder_04/erzeuge_*.py, die auch das
# zugehoerige SVG schreiben - aus demselben Lauf, damit beide Darstellungen
# dieselbe Loesung zeigen.
KARTE_RE = re.compile(r"^\{karte:([\w-]+)\}[ \t]*$", re.MULTILINE)
KARTEN_NAME = "karten"
KARTEN_VERZ = os.path.join(BILDER_VERZ, KARTEN_NAME)
def resolve_karte(text: str, fuer_html: bool) -> str:
"""Loest {karte:name}-Marken auf."""
def ersetze(m: re.Match) -> str:
name = m.group(1)
if not fuer_html:
# Wortlaut wie bei resolve_plotly(): Dieser Zweig bedient das PDF
# UND gesamtdokument.html - wer Letzteres liest, ist schon auf der
# Website, "auf der Website" waere dort also falsch.
return ("*Eine interaktive Karte dieser Touren steht auf den "
"Kapitelseiten der Website bereit.*")
pfad = os.path.join(KARTEN_VERZ, f"{name}.html")
if not os.path.exists(pfad):
print(f"WARNUNG: Karte fehlt: {pfad} - Marke entfernt.")
return ""
return ('```{=html}\n'
f'<iframe class="karte-figur" loading="lazy" '
f'src="{BILDER_NAME}/{KARTEN_NAME}/{name}.html" '
f'title="Interaktive Karte: {name}"></iframe>\n'
'```')
return KARTE_RE.sub(ersetze, text)
def resolve_plotly(text: str, fuer_html: bool) -> tuple[str, bool]:
"""Loest {plotly:name}-Marken auf. Liefert (Text, ob eine Figur eingebettet
@ -2138,6 +2195,7 @@ def baue_kapitel_seiten(html_verz: str, datei_seite: dict, labels: dict,
text = resolve_numbering_seite(text, eintrag["seite"], labels, label_seite)
text = resolve_index(text)
text, mit_plotly = resolve_plotly(text, fuer_html=True)
text = resolve_karte(text, fuer_html=True)
text = markiere_karten(text)
fragment = _pandoc_fragment(text, html_verz)