Statische Website-Assets in die Quelle web_04/ holen

site.css, site.js, icons.svg, plotly.min.js und die 22 KaTeX-Dateien lagen in
OR_HTML_04/, obwohl kein Skript sie je geschrieben hat. Die Falle daran: Wer
eine CSS-Regel suchte, suchte sie in den Quellen und fand nichts - genau das
ist beim Einruecken des ZIP-Menuepunkts passiert. Und wer OR_HTML_04/ geloescht
und neu gebaut haette, haette eine Website ohne Stil, ohne Symbole und ohne
Formelsatz bekommen.

Jetzt liegen sie in web_04/, dessen Aufbau (assets/, katex/) das Ziel spiegelt.
spiegle_statische_assets() kopiert sie bei JEDEM Lauf.
kopiere_plotly_bibliothek() fuellt die Quelle statt des Ziels.
_lade_icon_sprite_inline() und die beiden KaTeX-Pruefungen lesen die Quelle,
haengen also nicht mehr vom eigenen Ergebnis ab.

Zwei Waechter, weil genau diese Verwechslung schon vorgekommen ist:

* Wurde die Kopie in OR_HTML_04/ von Hand geaendert (Inhalt weicht ab UND
  Zeitstempel ist neuer), bricht der Bau ab und nennt den mv-Befehl, der es
  richtigstellt - kein stilles Ueberschreiben.
* pruefe_assets() liest die href=/src=-Literale aus dem Quelltext des
  Bauskripts und verlangt fuer jedes einen Erzeuger: entweder web_04/ oder die
  Liste ERZEUGTE_ASSETS (highlight.css, search-index.js, programme.js).

Probe: rm -rf OR_HTML_04 && --html baut alle 213 Dateien wieder auf,
Dateiliste identisch zur Sicherung.

Fund dabei: Das Stichwortverzeichnis war nicht byte-reproduzierbar

ziel_links() sortierte nach (seite, kontext) - und kontext ist der
Kapiteltitel, fuer alle Marken einer Datei also derselbe. Bei zwei Fundstellen
im selben Kapitel war der Schluessel gleich, und die Reihenfolge fiel auf die
eines set() zurueck, also auf den je Prozess zufaelligen PYTHONHASHSEED. Zwei
Laeufe erzeugten unterschiedliche Bytes ohne Quellaenderung.

Derselbe Fehler war auch sichtbar: vier {idx:Branch-and-Bound} in Kapitel 6
ergaben vier optisch identische Links nebeneinander; zehn Registereintraege
waren betroffen. Behoben durch einen Link je Kapitel (erste Fundstelle in
Dokumentreihenfolge, dict statt set) - das macht die Sortierung zugleich
eindeutig.

Gegenprobe: drei Laeufe mit PYTHONHASHSEED=random liefern dieselbe Pruefsumme.
219 Fachbegriffe und 328 Indexmarken unveraendert.

Nachtrag zum vorigen Commit: highlight.css gehoert NICHT zu den Handdateien -
erzeuge_highlight_css() erzeugt sie aus pandoc --print-highlight-style. Die
Notiz in PROGRESS.md ist korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dschlueter 2026-09-08 16:24:56 +02:00
commit 567bfa799a
30 changed files with 4686 additions and 22 deletions

View file

@ -25,6 +25,9 @@ Dieses Verzeichnis ist die Wurzel; alle Skripte leiten ihre Pfade daraus ab
```
Operations_Research_mit_Python_Version_04/ Quelle: 37 Kapiteldateien + Build-Skripte
bilder_04/ Quelle: Diagramme + erzeuge_*.py-Generatoren
web_04/ Quelle: site.css, site.js, icons.svg,
plotly.min.js, katex/ - die statischen
Bestandteile der Website
Operations_Research_mit_Python_Version_04.md generiert: Gesamtdokument
Operations_Research_mit_Python_Version_04.pdf generiert: PDF (xelatex)
OR_HTML_04/ generiert: Mehrseiten-Website
@ -39,7 +42,11 @@ LICENSE, LICENSE-TEXT.md MIT fuer Code, CC BY-SA 4.0 fuer
pandoc-defaults-basis.yaml, pandoc/, pandoc-defaults-buch.yaml PDF-Konfiguration
```
Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet.
Nur die als **Quelle** markierten Verzeichnisse werden von Hand bearbeitet. Seit dem
Umzug nach `web_04/` stimmt dieser Satz auch: Vorher lagen `site.css`, `site.js`,
`icons.svg` und `katex/` mitten im erzeugten `OR_HTML_04/`. `--check` bewacht das jetzt
(`pruefe_assets()`), und der Bau bricht ab, wenn jemand die Kopie statt der Quelle
bearbeitet hat.
Dieses Verzeichnis ist seit dem 08.09.2026 ein **eigenes Git-Repository** (`main`). Das
übergeordnete `OR_mit_Python/` ist nur noch das Archiv der Historie bis zur Trennung und

File diff suppressed because one or more lines are too long

View file

@ -32,6 +32,7 @@ import functools
import json
import os
import re
import shutil
import subprocess
import sys
import zipfile
@ -71,6 +72,19 @@ BILDER_VERZ = os.path.join(BASIS, BILDER_NAME)
HTML_NAME = f"OR_HTML_{VERSION}"
HTML_VERZ = os.path.join(BASIS, HTML_NAME)
# Statische Bestandteile der Website, die kein Skript erzeugt: das von Hand
# gepflegte Stylesheet und Skript, die Symbolsammlung, die mitgelieferte
# Plotly- und KaTeX-Bibliothek. Der Aufbau von web_04/ spiegelt das Ziel
# (assets/, katex/), damit spiegle_statische_assets() ein stumpfer
# Baumdurchlauf bleibt.
#
# Sie lagen frueher in OR_HTML_04/ selbst - also mitten im erzeugten
# Verzeichnis. Das war eine Falle: Wer eine CSS-Regel suchte, suchte sie in den
# Quellen und fand nichts; wer OR_HTML_04/ loeschte und neu baute, bekam eine
# Website ohne Stil.
WEB_NAME = f"web_{VERSION}"
WEB_VERZ = os.path.join(BASIS, WEB_NAME)
# Generierte Jupyter-Notebooks (eines je Kapitel).
NOTEBOOK_NAME = f"Notebooks_{VERSION}"
NOTEBOOK_VERZ = os.path.join(BASIS, NOTEBOOK_NAME)
@ -374,6 +388,49 @@ def pruefe_dateien() -> list[str]:
f"folgt aber '{soll}' - die Lesekette ueberspringt eine Datei.")
fehlend.append(name)
fehlend.extend(pruefe_assets())
return fehlend
# Dateien, die der Bau selbst in OR_HTML_04/assets/ erzeugt. Alles andere, was
# der Seitenkopf einbindet, muss aus der Quelle web_04/ kommen.
ERZEUGTE_ASSETS = {
"assets/highlight.css", # erzeuge_highlight_css() (pandoc)
"assets/search-index.js", # erzeuge_suchindex()
"assets/programme.js", # baue_programme_seite()
}
def pruefe_assets() -> list[str]:
"""Jede Datei, die der Seitenkopf per href=/src= einbindet, braucht einen
Erzeuger: entweder ein Skript (ERZEUGTE_ASSETS) oder die Quelle web_04/.
Ohne diese Pruefung entsteht beim naechsten Eintrag im Seitenkopf wieder
eine Datei ohne Herkunft - genau der Zustand, aus dem site.css, site.js,
icons.svg und katex/ gerade befreit wurden."""
# Gelesen wird der Quelltext dieses Skripts, nicht eine gerenderte Seite:
# Die Verweise stehen als Literale in mehreren Bausteinen (Seitenkopf,
# KATEX_KOPF, PLOTLY_SKRIPT), und eine Textsuche findet sie alle, ohne
# dass dafuer eine Seite gebaut werden muss.
with open(os.path.abspath(__file__), encoding="utf-8") as f:
eigener_quelltext = f.read()
verweise = sorted(set(re.findall(r'(?:href|src)="((?:assets|katex)/[^"]+)"',
eigener_quelltext)))
fehlend = []
for rel in verweise:
if rel in ERZEUGTE_ASSETS:
continue
if os.path.exists(os.path.join(WEB_VERZ, rel)):
continue
print(f"FEHLER: Der Seitenkopf bindet '{rel}' ein, aber niemand erzeugt "
f"die Datei - sie fehlt in {WEB_NAME}/ und in ERZEUGTE_ASSETS.")
fehlend.append(rel)
# icons.svg wird nicht verlinkt, sondern inline eingebettet (ICON_SPRITE_INLINE)
# und taucht deshalb nicht in `verweise` auf - hier separat geprueft.
if not os.path.exists(os.path.join(WEB_VERZ, "assets", "icons.svg")):
print(f"FEHLER: {WEB_NAME}/assets/icons.svg fehlt - die Seiten haetten "
"keine Symbole.")
fehlend.append("assets/icons.svg")
return fehlend
@ -1364,7 +1421,7 @@ def baue_gesamtdokument(html_verz: str) -> None:
"--standalone", "--wrap=none", "--toc", "--toc-depth=3",
"-M", f"pagetitle={SITE_TITEL} - Version {VERSION}",
"-M", "lang=de", "--highlight-style=breezedark", "-o", ziel]
if os.path.exists(os.path.join(html_verz, "katex")):
if os.path.exists(os.path.join(WEB_VERZ, "katex")):
befehl.insert(-2, "--katex=katex/")
if os.path.exists(stil):
befehl.extend(["-H", stil])
@ -1431,10 +1488,14 @@ def icon(name: str, klasse: str = "icon") -> str:
@functools.lru_cache(maxsize=1)
def _lade_icon_sprite_inline() -> str:
"""Liest assets/icons.svg und liefert nur die <symbol>-Definitionen
"""Liest web_04/assets/icons.svg und liefert nur die <symbol>-Definitionen
(ohne die aeussere <svg>-Huelle) zum Einbetten in jede Seite - siehe
icon()."""
pfad = os.path.join(HTML_VERZ, "assets", "icons.svg")
icon().
Gelesen wird die QUELLE, nicht die Kopie in OR_HTML_04/: Sonst haenge der
Bau von seinem eigenen Ergebnis ab und schluege bei leerem Zielverzeichnis
fehl."""
pfad = os.path.join(WEB_VERZ, "assets", "icons.svg")
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
innen = re.search(r"<svg[^>]*>(.*)</svg>", inhalt, re.DOTALL)
@ -1490,6 +1551,50 @@ def spiegle_bilder(html_verz: str) -> None:
print(f"Bilder gespiegelt: {kopiert} Datei(en) nach {HTML_NAME}/{BILDER_NAME}/")
def spiegle_statische_assets(html_verz: str) -> None:
"""Kopiert web_04/ nach OR_HTML_04/ - bei JEDEM Lauf, nicht nur wenn das
Ziel fehlt. Erst dadurch ist OR_HTML_04/ vollstaendig wiederherstellbar:
loeschen, neu bauen, fertig.
Der Waechter: Weicht die Zieldatei von der Quelle ab und ist sie neuer,
hat jemand die Kopie bearbeitet statt des Originals. Dann bricht der Bau
ab, statt die Arbeit still zu ueberschreiben - derselbe Fehler ist in
diesem Projekt schon vorgekommen."""
if not os.path.isdir(WEB_VERZ):
print(f"WARNUNG: Quellverzeichnis der Website-Assets fehlt: {WEB_VERZ}")
return
kopiert = 0
for wurzel, verzeichnisse, dateien in os.walk(WEB_VERZ):
verzeichnisse[:] = [v for v in verzeichnisse if not v.startswith(".")]
rel = os.path.relpath(wurzel, WEB_VERZ)
ziel_verz = html_verz if rel == "." else os.path.join(html_verz, rel)
os.makedirs(ziel_verz, exist_ok=True)
for name in sorted(dateien):
quelle = os.path.join(wurzel, name)
ziel = os.path.join(ziel_verz, name)
with open(quelle, "rb") as f:
daten = f.read()
if os.path.exists(ziel):
with open(ziel, "rb") as f:
vorhanden = f.read()
if vorhanden == daten:
continue
if os.path.getmtime(ziel) > os.path.getmtime(quelle):
rel_ziel = os.path.relpath(ziel, BASIS)
rel_quelle = os.path.relpath(quelle, BASIS)
raise SystemExit(
f"FEHLER: {rel_ziel} wurde geaendert, ist aber eine Kopie.\n"
f" Die Aenderung gehoert in die Quelle:\n"
f" mv {rel_ziel} {rel_quelle}")
with open(ziel, "wb") as f:
f.write(daten)
shutil.copystat(quelle, ziel)
kopiert += 1
gesamt = sum(len(d) for _, _, d in os.walk(WEB_VERZ))
print(f"Statische Assets: {gesamt} Datei(en) aus {WEB_NAME}/, "
f"davon {kopiert} neu geschrieben.")
def kopiere_pdf(html_verz: str) -> bool:
"""Kopiert das gebaute PDF nach OR_HTML_04/, damit der Download-Link in der
Sidebar funktioniert, sobald der Ordner auf einen Webserver kopiert wird.
@ -1651,7 +1756,7 @@ PANDOC_HTML_BASIS = ["-f", "markdown+autolink_bare_uris", "-t", "html5",
def _pandoc_fragment(text: str, html_verz: str) -> str:
befehl = ["pandoc"] + PANDOC_HTML_BASIS
if os.path.exists(os.path.join(html_verz, "katex")):
if os.path.exists(os.path.join(WEB_VERZ, "katex")):
befehl.append("--katex=katex/")
ergebnis = subprocess.run(befehl, cwd=BASIS, input=text,
capture_output=True, text=True, check=True)
@ -1725,14 +1830,20 @@ def resolve_plotly(text: str, fuer_html: bool) -> tuple[str, bool]:
return PLOTLY_RE.sub(ersetze, text), eingebettet
def kopiere_plotly_bibliothek(html_verz: str) -> bool:
"""Kopiert die von plotly mitgelieferte plotly.min.js nach assets/.
def kopiere_plotly_bibliothek() -> bool:
"""Holt die von plotly mitgelieferte plotly.min.js in die QUELLE
web_04/assets/ - von dort verteilt spiegle_statische_assets() sie.
Bewusst lokal statt per CDN: Die Website soll komplett offline
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets."""
ziel = os.path.join(html_verz, "assets", "plotly.min.js")
funktionieren - genau wie die bereits lokal eingebundenen KaTeX-Assets.
Nur wenn die Datei fehlt: Sie ist mit 4,8 MB versioniert und aendert sich
nur, wenn plotly aktualisiert wird. Wer sie auffrischen will, loescht sie
und baut neu."""
ziel = os.path.join(WEB_VERZ, "assets", "plotly.min.js")
if os.path.exists(ziel):
return True
os.makedirs(os.path.dirname(ziel), exist_ok=True)
try:
import plotly
quelle = os.path.join(os.path.dirname(plotly.__file__),
@ -2265,18 +2376,33 @@ def baue_stichwortverzeichnis(html_verz: str, struktur: list[dict],
haupt_name, _, unter_name = eintrag["roh"].partition("!")
haupt_name = haupt_name.rsplit("@", 1)[-1].strip()
ziel = (eintrag["seite"], eintrag["anker"], eintrag["kontext"])
knoten = haupt.setdefault(haupt_name, {"ziele": set(), "unter": {}})
knoten = haupt.setdefault(haupt_name, {"ziele": {}, "unter": {}})
if unter_name:
unter_name = unter_name.rsplit("@", 1)[-1].strip()
knoten["unter"].setdefault(unter_name, set()).add(ziel)
knoten["unter"].setdefault(unter_name, {})[ziel] = None
else:
knoten["ziele"].add(ziel)
knoten["ziele"][ziel] = None
def ziel_links(ziele: set) -> str:
eintraege = sorted(ziele, key=lambda z: (z[0], z[2]))
def ziel_links(ziele: dict) -> str:
# Ein Link je Kapitel, nicht je Fundstelle. 'kontext' ist der
# Kapiteltitel und damit fuer alle Marken einer Datei gleich - vier
# {idx:Branch-and-Bound} in Kapitel 6 ergaben vier optisch identische
# Links nebeneinander. Behalten wird die ERSTE Fundstelle; die Marken
# kommen in Dokumentreihenfolge an, weil finditer() den Text linear
# durchlaeuft und dict die Einfuegereihenfolge bewahrt.
#
# Die Deduplizierung macht die anschliessende Sortierung zugleich
# eindeutig. Vorher war sie es nicht: Der Schluessel (seite, kontext)
# war fuer zwei Fundstellen desselben Kapitels identisch, und die
# Reihenfolge fiel auf die eines set() zurueck - also auf den je Prozess
# zufaelligen PYTHONHASHSEED. Zwei Laeufe erzeugten unterschiedliche
# Bytes, ohne dass sich eine Quelle geaendert haette.
je_kapitel: dict[tuple[str, str], str] = {}
for seite, anker, kontext in ziele:
je_kapitel.setdefault((seite, kontext), anker)
return ", ".join(
f'<a href="{seite}{"#" + anker if anker else ""}">{kontext}</a>'
for seite, anker, kontext in eintraege)
for (seite, kontext), anker in sorted(je_kapitel.items()))
namen = sorted(haupt.keys(), key=_sortierschluessel)
sprungleiste = " ".join(
@ -2346,12 +2472,13 @@ def baue_html_website() -> None:
os.makedirs(html_verz, exist_ok=True)
datei_seite, labels, label_seite, struktur, stichwortregister = baue_seiten_registry()
kopiere_plotly_bibliothek()
spiegle_statische_assets(html_verz)
spiegle_bilder(html_verz)
erzeuge_highlight_css(html_verz)
pdf_verfuegbar = kopiere_pdf(html_verz)
notizbuecher = baue_notebooks(struktur)
spiegle_notebooks(html_verz, notizbuecher)
kopiere_plotly_bibliothek(html_verz)
suchindex: list[dict] = []
baue_kapitel_seiten(html_verz, datei_seite, labels, label_seite, struktur,
suchindex, pdf_verfuegbar, notizbuecher)

View file

@ -2024,14 +2024,60 @@ Archivs — so können Link und Datei nicht auseinanderlaufen. Das Glossar ersch
dasselbe leisten, und unten in der Gruppe „Anhänge", weil es ein Anhang ist.
**Nebenbefund: `OR_HTML_04/assets/site.css` ist eine Quelle, keine erzeugte Datei.** Der
Build schreibt sie nicht (Zeitstempel vom 06.09., während `programme.js` bei jedem Lauf
neu entsteht). Dasselbe gilt für `site.js`, `icons.svg`, `highlight.css` und
`plotly.min.js`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
Build schreibt sie nicht. Dasselbe gilt für `site.js`, `icons.svg`, `plotly.min.js` und
`katex/`. Sie liegen im als „generiert" beschriebenen Verzeichnis, sind aber von Hand
gepflegt bzw. mitgeliefert — wer `OR_HTML_04/` löscht und neu baut, verliert sie.
(Nachtrag: Der nächste Abschnitt räumt das auf. **Und `highlight.css` gehört nicht in diese
Liste** — die erzeugt `erzeuge_highlight_css()` aus `pandoc --print-highlight-style`; die
erste Fassung dieses Absatzes zählte sie fälschlich mit.)
Die alte `anhang-glossar-literatur.html` musste von Hand entfernt werden; der Build räumt
verwaiste Seiten nicht ab.
### ✅ `web_04/` — die statischen Assets bekommen eine Quelle
Vier Dateien und die 22 KaTeX-Dateien lagen im erzeugten Verzeichnis, obwohl kein Skript sie
je geschrieben hat. Die Falle daran: Wer eine CSS-Regel suchte, suchte sie in den Quellen und
fand nichts — genau das ist beim Einrücken des ZIP-Menüpunkts passiert. Und wer `OR_HTML_04/`
gelöscht und neu gebaut hätte, hätte eine Website ohne Stil, ohne Symbole und ohne
Formelsatz bekommen.
Jetzt liegen sie in `web_04/`, dessen Aufbau (`assets/`, `katex/`) das Ziel spiegelt.
`spiegle_statische_assets()` kopiert sie bei **jedem** Lauf; `kopiere_plotly_bibliothek()`
füllt die Quelle statt des Ziels; `_lade_icon_sprite_inline()` liest die Quelle, hängt also
nicht mehr vom eigenen Ergebnis ab.
**Zwei Wächter**, weil genau diese Verwechslung schon vorgekommen ist:
* Wurde die Kopie in `OR_HTML_04/` von Hand geändert (Inhalt weicht ab *und* Zeitstempel ist
neuer), **bricht der Bau ab** und nennt den `mv`-Befehl, der es richtigstellt. Kein
stilles Überschreiben.
* `pruefe_assets()` liest die `href=`/`src=`-Literale aus dem Quelltext des Bauskripts und
verlangt für jedes einen Erzeuger — entweder `web_04/` oder die Liste `ERZEUGTE_ASSETS`
(`highlight.css`, `search-index.js`, `programme.js`). Ein künftiger Eintrag im Seitenkopf
ohne Datei dahinter fällt sofort auf.
Die Probe aufs Exempel: `rm -rf OR_HTML_04 && --html` baut alle 213 Dateien wieder auf,
Dateiliste identisch zur Sicherung.
### 🐛 Fund: Das Stichwortverzeichnis war nicht byte-reproduzierbar
Beim Gegenprüfen der Neubau-Probe unterschied sich `stichwortverzeichnis.html` zwischen zwei
Läufen, ohne dass sich eine Quelle geändert hatte. Ursache in `ziel_links()`: sortiert wurde
nach `(seite, kontext)` — und `kontext` ist der **Kapiteltitel**, für alle Marken einer Datei
also derselbe. Bei zwei Fundstellen im selben Kapitel war der Schlüssel gleich, und die
Reihenfolge fiel auf die eines `set()` zurück, also auf den je Prozess zufälligen
`PYTHONHASHSEED`.
Derselbe Konstruktionsfehler war auch sichtbar: Vier `{idx:Branch-and-Bound}` in Kapitel 6
ergaben **vier optisch identische Links** nebeneinander — zehn Einträge im Register waren
davon betroffen. Behoben durch einen Link je Kapitel (die erste Fundstelle in
Dokumentreihenfolge, `dict` statt `set`). Damit ist die Sortierung zugleich eindeutig.
Gegenprobe: drei Läufe mit `PYTHONHASHSEED=random` liefern dieselbe Prüfsumme
`3da5b9b2…`. Keine Hauptzeile trägt mehr einen doppelten Link; die 219 Fachbegriffe und 328
Indexmarken bleiben unverändert.
---
## 8. Commit-Historie des V04-Strangs

41
web_04/assets/icons.svg Normal file
View file

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.2" y2="16.2"/>
</symbol>
<symbol id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4.5"/>
<line x1="12" y1="1.5" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22.5"/>
<line x1="1.5" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22.5" y2="12"/>
<line x1="4.5" y1="4.5" x2="6.2" y2="6.2"/><line x1="17.8" y1="17.8" x2="19.5" y2="19.5"/>
<line x1="19.5" y1="4.5" x2="17.8" y2="6.2"/><line x1="6.2" y1="17.8" x2="4.5" y2="19.5"/>
</symbol>
<symbol id="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5z"/>
</symbol>
<symbol id="icon-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 4 7 12 15 20"/>
</symbol>
<symbol id="icon-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 4 17 12 9 20"/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 13 9.5 18.5 20 6"/>
</symbol>
<symbol id="icon-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13.5V19a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V7A1.5 1.5 0 0 1 5 5.5h5.5"/>
<polyline points="14.5 3.5 20.5 3.5 20.5 9.5"/><line x1="11" y1="13" x2="20" y2="4"/>
</symbol>
<symbol id="icon-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 5.5A2 2 0 0 1 6 4h6v16H6a2 2 0 0 0-2 1.5z"/>
<path d="M20 5.5A2 2 0 0 0 18 4h-6v16h6a2 2 0 0 1 2 1.5z"/>
</symbol>
<symbol id="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</symbol>
<symbol id="icon-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12"/><polyline points="7 10 12 15 17 10"/><path d="M4 19.5h16"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

3882
web_04/assets/plotly.min.js vendored Normal file

File diff suppressed because one or more lines are too long

348
web_04/assets/site.css Normal file
View file

@ -0,0 +1,348 @@
/* site.css Design-System der Mehrseiten-Website "Operations Research mit
Python". Struktur (CSS-Variablen, .dark-Umschaltung, kuehle Graustufen)
nach dem Vorbild https://jamulix.de/sorting/, Farben aus der bestehenden
Buchpalette (Titelseite/PDF: Indigo/Cyan/Violett/Gruen/Amber). */
:root {
--bg: #f5f6fa;
--surface: #ffffff;
--surface2: #eef0f5;
--border: #d9dce6;
--text: #1a1a2e;
--muted: #5c6380;
--accent: #4338ca;
--accent-text: #ffffff;
--cyan: #0891b2;
--violett: #7c3aed;
--emerald: #059669;
--amber: #b45309;
--rose: #e11d48;
--tint-indigo: #eef2ff;
--tint-violett: #f5f3ff;
--tint-cyan: #ecfeff;
--tint-emerald: #ecfdf5;
--tint-amber: #fffbeb;
--code-bg: #1e293b;
--code-text: #e6edf3;
--shadow: 0 2px 10px rgba(26, 26, 46, .07);
--shadow-lg: 0 8px 30px rgba(26, 26, 46, .12);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0e0f1a;
--surface: #161826;
--surface2: #1e2133;
--border: #2b2f45;
--text: #e7e9f5;
--muted: #9aa1c4;
--accent: #818cf8;
--accent-text: #0e0f1a;
--cyan: #22d3ee;
--violett: #c4b5fd;
--emerald: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
--tint-indigo: #1b1f3a;
--tint-violett: #241f3d;
--tint-cyan: #132833;
--tint-emerald: #10291f;
--tint-amber: #2c2410;
--code-bg: #0b0d14;
--code-text: #dbe4f0;
--shadow: 0 2px 10px rgba(0, 0, 0, .35);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, .5);
}
}
:root[data-theme="dark"] {
--bg: #0e0f1a;
--surface: #161826;
--surface2: #1e2133;
--border: #2b2f45;
--text: #e7e9f5;
--muted: #9aa1c4;
--accent: #818cf8;
--accent-text: #0e0f1a;
--cyan: #22d3ee;
--violett: #c4b5fd;
--emerald: #34d399;
--amber: #fbbf24;
--rose: #fb7185;
--tint-indigo: #1b1f3a;
--tint-violett: #241f3d;
--tint-cyan: #132833;
--tint-emerald: #10291f;
--tint-amber: #2c2410;
--code-bg: #0b0d14;
--code-text: #dbe4f0;
--shadow: 0 2px 10px rgba(0, 0, 0, .35);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, .5);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, "DejaVu Sans", Arial, sans-serif;
line-height: 1.7; font-size: 16px; -webkit-text-size-adjust: 100%;
}
.icon { width: 1.15em; height: 1.15em; flex: none; vertical-align: -.2em; }
/* --- Kopfzeile --- */
.site-header {
position: sticky; top: 0; z-index: 30; display: flex; align-items: center;
gap: .75rem; padding: .6rem 1rem; background: var(--surface);
border-bottom: 1px solid var(--border); box-shadow: var(--shadow);
}
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 2.2rem; height: 2.2rem; border-radius: 8px; border: 1px solid transparent;
background: transparent; color: var(--text); cursor: pointer;
}
.icon-btn:hover { background: var(--surface2); border-color: var(--border); }
.brand {
display: flex; align-items: center; gap: .5rem; color: var(--accent);
font-weight: 700; text-decoration: none; white-space: nowrap;
}
.brand span { display: none; }
@media (min-width: 900px) { .brand span { display: inline; } }
.site-search { position: relative; flex: 1; max-width: 420px; margin: 0 auto; }
.site-search input {
width: 100%; padding: .5rem .8rem .5rem 2.2rem; border-radius: 20px;
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
font-size: .92rem;
}
.site-search input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.such-icon { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); color: var(--muted); }
.suche-ergebnisse {
position: absolute; top: calc(100% + .4rem); left: 0; right: 0; max-height: 60vh;
overflow-y: auto; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg); z-index: 40;
}
.suche-ergebnisse a {
display: block; padding: .55rem .9rem; color: var(--text); text-decoration: none;
border-bottom: 1px solid var(--border);
}
.suche-ergebnisse a:last-child { border-bottom: none; }
.suche-ergebnisse a:hover, .suche-ergebnisse a.aktiv { background: var(--tint-indigo); }
.suche-ergebnisse small { display: block; color: var(--muted); font-weight: 400; }
.suche-leer { padding: .8rem .9rem; color: var(--muted); }
#theme-toggle .icon-moon { display: none; }
:root[data-theme="dark"] #theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] #theme-toggle .icon-moon { display: inline; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) #theme-toggle .icon-sun { display: none; }
:root:not([data-theme="light"]) #theme-toggle .icon-moon { display: inline; }
}
/* --- Layout: Sidebar + Inhalt --- */
.site-body { display: flex; align-items: flex-start; max-width: 1400px; margin: 0 auto; }
.sidebar {
position: sticky; top: 3.6rem; width: 290px; flex: none;
height: calc(100vh - 3.6rem); overflow-y: auto; border-right: 1px solid var(--border);
background: var(--surface); padding: 1rem 0;
}
.sidebar-gruppe { border-bottom: 1px solid var(--border); }
.sidebar-gruppe summary {
cursor: pointer; padding: .6rem 1rem; font-weight: 700; color: var(--accent);
list-style: none; display: flex; align-items: center; justify-content: space-between;
}
.sidebar-gruppe summary::-webkit-details-marker { display: none; }
.sidebar-gruppe summary::after { content: "▾"; color: var(--muted); font-size: .8em; }
.sidebar-gruppe[open] summary::after { content: "▴"; }
.sidebar ul { list-style: none; margin: 0; padding: 0 .5rem .5rem; }
.sidebar li a {
display: flex; align-items: center; gap: .5rem; padding: .4rem .6rem;
border-radius: 8px; color: var(--text); text-decoration: none; font-size: .92rem;
}
.sidebar li a:hover { background: var(--tint-indigo); }
.sidebar li.aktiv a { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.fortschritt-haken { width: 1em; height: 1em; color: var(--emerald); visibility: hidden; }
.fortschritt-haken .icon { width: 100%; height: 100%; }
li[data-erledigt="1"] .fortschritt-haken { visibility: visible; }
.sidebar-extra { border-top: 2px solid var(--border); margin-top: .5rem; padding-top: .8rem !important; }
/* Unterpunkt in der festen Menueliste (Download-ZIP unter Notebooks): eingerueckt und etwas kleiner, ohne echte Listenverschachtelung. */
.sidebar-unterpunkt a { padding-left: 1.9rem !important; font-size: .92em; }
.sidebar-overlay { display: none; }
.content { flex: 1; min-width: 0; padding: 1.5rem 2rem 5rem; max-width: 900px; margin: 0 auto; }
/* --- Breadcrumb, Prev/Next --- */
.breadcrumb { font-size: .85rem; color: var(--muted); margin-bottom: .8rem; }
.breadcrumb a { color: var(--muted); }
.prev-next { display: flex; justify-content: space-between; gap: 1rem; margin: 1.2rem 0; }
.prev-next-knopf {
display: flex; align-items: center; gap: .6rem; flex: 1; max-width: 48%;
padding: .7rem 1rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text); text-decoration: none; font-size: .9rem;
box-shadow: var(--shadow); transition: border-color .15s, transform .15s;
}
.prev-next-knopf:hover { border-color: var(--accent); transform: translateY(-1px); }
.prev-next-next { margin-left: auto; text-align: right; flex-direction: row-reverse; }
.prev-next-knopf small { display: block; color: var(--muted); font-weight: 400; }
.prev-next-leer { flex: 1; max-width: 48%; }
.fortschritt-knopf {
display: inline-flex; align-items: center; gap: .5rem; margin: 1rem 0;
padding: .55rem 1rem; border-radius: 20px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text); cursor: pointer; font: inherit; font-size: .88rem;
}
.fortschritt-knopf .icon { color: var(--muted); }
.fortschritt-knopf.erledigt { background: var(--tint-emerald); border-color: var(--emerald); color: var(--emerald); }
.fortschritt-knopf.erledigt .icon { color: var(--emerald); }
.site-footer { text-align: center; padding: 1.5rem; color: var(--muted); font-size: .85rem; border-top: 1px solid var(--border); }
.site-footer a { color: var(--muted); }
/* --- Typografie im Artikel (angelehnt an bilder/or_html_style.html) --- */
article h1, article h2, article h3, article h4, article h5 {
line-height: 1.25; font-weight: 700; margin-top: 2.1em; margin-bottom: .6em; scroll-margin-top: 4.5rem;
}
article h1 { color: var(--accent); font-size: 1.9rem; border-bottom: 3px solid var(--accent); padding-bottom: .25em; }
article h2 { color: var(--accent); font-size: 1.45rem; border-bottom: 2px solid var(--border); padding-bottom: .2em; }
article h3 { color: var(--cyan); font-size: 1.18rem; }
article h4 { color: var(--violett); font-size: 1.05rem; }
article h2[id$="-lernziele"]::before { content: "🎯 "; }
article h2[id$="-uebungsaufgaben"]::before { content: "📝 "; }
article h2[id$="-selbsttest"]::before { content: "✅ "; }
article h2[id$="-zusammenfassung"]::before { content: "📌 "; }
article a { color: var(--accent); text-decoration: none; }
article a:hover { text-decoration: underline; }
article a[target="_blank"]::after { content: " \2197"; font-size: .82em; color: var(--cyan); text-decoration: none; }
article p { margin: .75em 0; }
article hr { border: 0; border-top: 1px solid var(--border); margin: 2.2em 0; }
article figure { margin: 2em 0; text-align: center; }
article img { max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 10px; }
article figure img { box-shadow: var(--shadow-lg); }
article figcaption { color: var(--muted); font-size: .9rem; font-style: italic; margin-top: .6em; }
article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .94rem; box-shadow: var(--shadow); border-radius: 8px; overflow: hidden; }
article th { background: var(--accent); color: var(--accent-text); font-weight: 700; text-align: left; }
article th, article td { padding: .55em .75em; border: 1px solid var(--border); vertical-align: top; }
article tbody tr:nth-child(even) { background: var(--tint-indigo); }
article code { font-family: "DejaVu Sans Mono", Consolas, Menlo, monospace; font-size: .9em; background: var(--tint-violett); color: var(--violett); padding: .12em .4em; border-radius: 5px; }
article pre { background: var(--code-bg); color: var(--code-text); padding: 1.1em 1.2em; border-radius: 10px; overflow-x: auto; line-height: 1.5; font-size: .85rem; box-shadow: var(--shadow-lg); }
article pre code { background: none; color: inherit; padding: 0; font-size: inherit; }
article div.sourceCode { position: relative; }
article div.sourceCode pre.sourceCode { padding-top: 2.6em; }
article blockquote { border-left: 4px solid var(--violett); background: var(--tint-violett); margin: 1.4em 0; padding: .6em 1.2em; border-radius: 0 8px 8px 0; }
article ul, article ol { padding-left: 1.4em; }
article li { margin: .3em 0; }
.katex-display { overflow-x: auto; overflow-y: hidden; padding: .4em 0; }
/* --- Code-Buttons (Kopieren / Herunterladen) --- */
.code-btn-leiste { position: absolute; top: .55em; right: .55em; z-index: 2; display: flex; gap: .4em; }
.code-btn, .download-link {
display: inline-flex; align-items: center; gap: .35em; background: rgba(255,255,255,.08);
color: #cbd5e1; border: 1px solid rgba(255,255,255,.18); border-radius: 6px;
padding: .3em .65em; font: inherit; font-size: .78rem; cursor: pointer;
text-decoration: none !important; transition: background .15s, color .15s;
}
.code-btn:hover, .download-link:hover { background: rgba(255,255,255,.18); color: #fff; }
.code-btn.copied { background: var(--emerald); color: #fff; border-color: var(--emerald); }
.download-link { position: static; margin-left: .6em; }
/* --- Karten (Kapitel auf einen Blick, Lernpfade, Kapitelraster) --- */
.card { border-radius: var(--radius); padding: 1.1em 1.4em; margin: 1.6em 0; box-shadow: var(--shadow); }
.card p, .card blockquote { margin: .5em 0 !important; border: none !important; background: none !important; padding: 0 !important; }
.card-blick { background: var(--tint-indigo); border: 1px solid var(--accent); }
.card-blick blockquote { border-left: 3px solid var(--accent); padding-left: .8em !important; }
/* --- Didaktische Karten der Version 04 ---------------------------------
Jede Kartenart traegt eine eigene Leitfarbe aus der Buchpalette, damit
Kursteilnehmer beim Blaettern sofort erkennen, was sie vor sich haben:
gruen = sofort ausprobieren, cyan = Formel im Klartext, violett = Excel,
amber = Selbstkontrolle, rose = hier steckt ein Fehler drin. Die
Leitfarbe liegt links als 5px-Balken, damit sie auch im Ausdruck und in
der Druckvorschau erkennbar bleibt. */
.card-schnellstart,
.card-formel,
.card-excel,
.card-quiz,
.card-denkfehler { border: 1px solid var(--border); border-left-width: 5px; }
.card-schnellstart { background: var(--tint-emerald); border-left-color: var(--emerald); }
.card-formel { background: var(--tint-cyan); border-left-color: var(--cyan); }
.card-excel { background: var(--tint-violett); border-left-color: var(--violett); }
.card-quiz { background: var(--tint-amber); border-left-color: var(--amber); }
.card-denkfehler { background: var(--tint-amber); border-left-color: var(--rose); }
/* Die Karten enthalten haeufig Tabellen (Formel-Uebersetzer) und
Codebloecke (Schnellstart, Denkfehler). Beide sollen sich klar vom
getoenten Kartengrund abheben statt darin zu verschwimmen. */
.card-schnellstart pre,
.card-denkfehler pre { margin: .8em 0 !important; }
.card-formel table { margin: .6em 0; background: var(--surface); }
.card-formel td:first-child { white-space: nowrap; }
/* --- Interaktive Plotly-Figuren ---------------------------------------
Stehen im Text unmittelbar hinter der statischen Fassung (die im PDF
gedruckt wird). Auf schmalen Displays darf die Figur horizontal
scrollen, statt die Seite selbst breit zu machen. */
.plotly-figur { margin: 1.4em 0; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: .6em; overflow-x: auto; }
.plotly-figur .plotly-graph-div { min-width: 320px; }
/* Der Formel-Uebersetzer ist zweispaltig gedacht: links die Mathematik,
rechts der Klartext. Auf schmalen Displays wuerde die Formelspalte die
Tabelle sonst aus dem Viewport druecken. */
@media (max-width: 640px) {
.card-formel td:first-child { white-space: normal; }
}
.kartenraster { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1rem; margin: 1rem 0 2rem; }
.karte { display: block; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.1rem; box-shadow: var(--shadow); text-decoration: none; color: var(--text); }
.karte:hover { border-color: var(--accent); transform: translateY(-2px); transition: transform .15s, border-color .15s; }
.karte h3 { margin: .3em 0; color: var(--text); font-size: 1.05rem; }
.karte p { color: var(--muted); font-size: .88rem; margin: .4em 0; }
.karte-nummer { font-size: .78rem; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: .03em; }
.karte-zeit { font-size: .8rem; color: var(--muted); }
.karte-pfad { background: var(--tint-violett); border-color: var(--violett); }
.karte-einstieg { background: var(--tint-cyan); border-color: var(--cyan); }
.kapitelgruppe { margin: 2.5rem 0; }
.kapitelgruppe h2 { color: var(--accent); border-bottom: 2px solid var(--border); padding-bottom: .3em; }
.lernpfade h2 { color: var(--violett); }
/* --- Landing-Page Hero --- */
.hero { margin: 1rem 0 2.5rem; }
.hero-kicker { color: var(--cyan); letter-spacing: .12em; font-weight: 700; font-size: .85rem; }
.hero-text h1 { font-size: 2.1rem; color: var(--accent); margin: .2em 0; }
.hero-untertitel { color: var(--muted); font-size: 1.05rem; }
.hero-badges { display: flex; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
.hero-kompendium { color: var(--muted); margin-top: 1.4rem; max-width: 640px; }
.hero-autor { color: var(--muted); font-size: .92rem; line-height: 1.5; }
.badge { padding: .35em .9em; border-radius: 20px; color: #fff; font-weight: 600; font-size: .85rem; }
.badge-indigo { background: var(--accent); }
.badge-cyan { background: var(--cyan); }
.badge-violett { background: var(--violett); }
.badge-emerald { background: var(--emerald); }
/* --- Programme-Seite --- */
.programmgruppe { margin: 2rem 0; }
.programmliste { list-style: none; padding: 0; }
.programmliste li { display: flex; align-items: center; justify-content: space-between; padding: .5em .8em; border: 1px solid var(--border); border-radius: 8px; margin: .4em 0; background: var(--surface); }
/* --- Stichwortverzeichnis --- */
.register-sprungleiste { display: flex; flex-wrap: wrap; gap: .3rem; margin: 1.2rem 0 1.8rem; position: sticky; top: 3.6rem; background: var(--bg); padding: .5rem 0; z-index: 5; }
.register-sprungleiste a { display: inline-flex; align-items: center; justify-content: center; width: 1.8rem; height: 1.8rem; border-radius: 6px; background: var(--surface2); color: var(--accent); text-decoration: none; font-weight: 700; font-size: .85rem; }
.register-sprungleiste a:hover { background: var(--tint-indigo); }
.register-buchstabe { color: var(--accent); border-bottom: 2px solid var(--border); padding-bottom: .15em; margin-top: 2em; font-size: 1.3rem; }
.register-eintrag { padding: .55em 0; border-bottom: 1px solid var(--border); }
.register-begriff { font-weight: 700; }
.register-eintrag a { font-size: .92rem; }
.register-unter { list-style: none; margin: .4em 0 0; padding-left: 1.4em; }
.register-unter li { margin: .3em 0; color: var(--muted); font-size: .92rem; }
.register-unter a { font-size: inherit; }
/* --- Mobile --- */
@media (max-width: 899px) {
.sidebar {
position: fixed; top: 0; left: 0; height: 100vh; z-index: 50; transform: translateX(-105%);
transition: transform .2s ease; box-shadow: var(--shadow-lg); padding-top: 3.6rem;
}
.sidebar.offen { transform: translateX(0); }
.sidebar-overlay.offen { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 40; }
.content { padding: 1.2rem 1rem 4rem; }
.site-search { max-width: none; }
}

211
web_04/assets/site.js Normal file
View file

@ -0,0 +1,211 @@
// site.js — Interaktivität der Mehrseiten-Website "Operations Research mit
// Python": Dark-Mode-Toggle, mobile Sidebar, Volltextsuche, Lernfortschritt,
// Code-Kopieren/Herunterladen. Reines Vanilla-JS, keine Abhaengigkeiten.
(function () {
"use strict";
var LS_THEME = "or-theme";
var LS_FORTSCHRITT = "or-fortschritt";
function ladeFortschritt() {
try {
return JSON.parse(localStorage.getItem(LS_FORTSCHRITT) || "{}");
} catch (e) {
return {};
}
}
function speichereFortschritt(daten) {
try {
localStorage.setItem(LS_FORTSCHRITT, JSON.stringify(daten));
} catch (e) {}
}
// --- Dark-Mode-Toggle -------------------------------------------------
function initTheme() {
var btn = document.getElementById("theme-toggle");
if (!btn) return;
btn.addEventListener("click", function () {
var aktuell = document.documentElement.getAttribute("data-theme");
var dunkelSystem = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
var istDunkel = aktuell ? aktuell === "dark" : dunkelSystem;
var neu = istDunkel ? "light" : "dark";
document.documentElement.setAttribute("data-theme", neu);
try { localStorage.setItem(LS_THEME, neu); } catch (e) {}
});
}
// --- Mobile Sidebar -----------------------------------------------------
function initSidebar() {
var sidebar = document.getElementById("sidebar");
var overlay = document.getElementById("sidebar-overlay");
var knopf = document.getElementById("sidebar-toggle");
if (!sidebar || !overlay || !knopf) return;
function schliessen() {
sidebar.classList.remove("offen");
overlay.classList.remove("offen");
overlay.hidden = true;
}
knopf.addEventListener("click", function () {
var offen = sidebar.classList.toggle("offen");
overlay.hidden = !offen;
overlay.classList.toggle("offen", offen);
});
overlay.addEventListener("click", schliessen);
sidebar.querySelectorAll("a").forEach(function (a) {
a.addEventListener("click", schliessen);
});
// Aktuelle Kapitelgruppe aufklappen, falls noch nicht offen.
var aktiv = sidebar.querySelector("li.aktiv");
if (aktiv) {
var details = aktiv.closest("details");
if (details) details.open = true;
// Nur den Sidebar-eigenen Scrollbereich verschieben, NICHT
// aktiv.scrollIntoView() verwenden: das wuerde auch das ganze Fenster
// scrollen und einen #anker-Sprung aus der URL (z.B. von einem
// Querverweis) wieder zunichtemachen.
sidebar.scrollTop = Math.max(0, aktiv.offsetTop - sidebar.clientHeight / 2);
}
}
// --- Lernfortschritt ------------------------------------------------
function initFortschritt() {
var daten = ladeFortschritt();
document.querySelectorAll('.sidebar li[data-kapitel]').forEach(function (li) {
if (daten[li.dataset.kapitel]) li.dataset.erledigt = "1";
});
var knopf = document.querySelector(".fortschritt-knopf");
if (!knopf) return;
var kapitel = knopf.dataset.kapitel;
function aktualisiereKnopf() {
var erledigt = !!daten[kapitel];
knopf.classList.toggle("erledigt", erledigt);
knopf.querySelector("span").textContent =
erledigt ? "Als gelesen markiert" : "Als gelesen markieren";
var eintrag = document.querySelector(
'.sidebar li[data-kapitel="' + kapitel + '"]');
if (eintrag) eintrag.dataset.erledigt = erledigt ? "1" : "0";
}
aktualisiereKnopf();
knopf.addEventListener("click", function () {
daten[kapitel] = !daten[kapitel];
speichereFortschritt(daten);
aktualisiereKnopf();
});
}
// --- Suche ------------------------------------------------------------
function initSuche() {
var eingabe = document.getElementById("suche-eingabe");
var ergebnisBox = document.getElementById("suche-ergebnisse");
if (!eingabe || !ergebnisBox) return;
// window.OR_SEARCH_INDEX kommt aus assets/search-index.js (als normales
// <script> geladen, nicht per fetch()) - fetch() auf lokale Dateien
// scheitert unter file:// (Seite ohne Webserver geoeffnet) an CORS.
var index = window.OR_SEARCH_INDEX || [];
function anzeigen(treffer) {
if (!treffer.length) {
ergebnisBox.innerHTML = '<p class="suche-leer">Keine Treffer.</p>';
ergebnisBox.hidden = false;
return;
}
ergebnisBox.innerHTML = treffer.slice(0, 20).map(function (t) {
var href = t.seite + (t.anker ? "#" + t.anker : "");
var snippet = t.text ? "<small>" + t.text + "</small>" : "";
return '<a href="' + href + '">' + t.titel + snippet + "</a>";
}).join("");
ergebnisBox.hidden = false;
}
eingabe.addEventListener("input", function () {
var q = eingabe.value.trim().toLowerCase();
if (q.length < 2) { ergebnisBox.hidden = true; return; }
var treffer = index.filter(function (e) {
return (e.titel && e.titel.toLowerCase().indexOf(q) !== -1) ||
(e.text && e.text.toLowerCase().indexOf(q) !== -1);
});
anzeigen(treffer);
});
document.addEventListener("click", function (ev) {
if (!ergebnisBox.contains(ev.target) && ev.target !== eingabe) {
ergebnisBox.hidden = true;
}
});
eingabe.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") { ergebnisBox.hidden = true; eingabe.blur(); }
});
}
// --- Code: Kopieren-Button (+ Herunterladen, falls Programmdatei bekannt) --
function initCodeButtons() {
// window.OR_PROGRAMME kommt aus assets/programme.js (normales <script>,
// kein fetch() - siehe initSuche()).
var programmListe = window.OR_PROGRAMME || [];
document.querySelectorAll("div.sourceCode").forEach(function (box) {
var code = box.querySelector("code");
if (!code) return;
var leiste = document.createElement("div");
leiste.className = "code-btn-leiste";
var kopieren = document.createElement("button");
kopieren.type = "button";
kopieren.className = "code-btn";
kopieren.setAttribute("aria-label", "Code kopieren");
kopieren.innerHTML =
'<svg class="icon"><use href="#icon-copy"></use></svg><span>Kopieren</span>';
kopieren.addEventListener("click", function () {
navigator.clipboard.writeText(code.innerText).then(function () {
var label = kopieren.querySelector("span");
var original = label.textContent;
kopieren.classList.add("copied");
label.textContent = "Kopiert!";
setTimeout(function () {
kopieren.classList.remove("copied");
label.textContent = original;
}, 1500);
});
});
leiste.appendChild(kopieren);
box.appendChild(leiste);
// Erste Codezeile "# Name.py" (nach Shebang) -> Download-Button, wenn
// eine passende Datei unter programme/ existiert (Programme.py werden
// beim Bau nach OR_HTML/programme/ kopiert, siehe build_version_03.py).
var text = code.innerText;
var treffer = text.match(/^#\s*([A-Za-z0-9_]+\.py)\s*$/m);
if (treffer && programmListe.indexOf(treffer[1]) !== -1) {
var link = document.createElement("a");
link.className = "code-btn";
link.href = "programme/" + treffer[1];
link.setAttribute("download", "");
link.innerHTML =
'<svg class="icon"><use href="#icon-download"></use></svg><span>Herunterladen</span>';
leiste.appendChild(link);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
initTheme();
initSidebar();
initFortschritt();
initSuche();
initCodeButtons();
});
// Bilder ohne width/height-Attribut (siehe Kapitelseiten) reflowen den
// Text erst, wenn sie fertig geladen sind - das passiert erst NACH
// DOMContentLoaded. Ein #anker-Sprung aus der URL (Querverweis, Such-
// treffer, Stichwortverzeichnis) kann dadurch zu weit oben landen. Nach
// vollstaendigem Laden (inkl. Bilder) hier zur Sicherheit erneut hinspringen.
window.addEventListener("load", function () {
if (!location.hash) return;
var ziel = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (ziel) ziel.scrollIntoView({ behavior: "instant", block: "start" });
});
})();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
web_04/katex/katex.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
web_04/katex/katex.min.js vendored Normal file

File diff suppressed because one or more lines are too long