feat: add python and web implementation

This commit is contained in:
teier committed 2026-10-28 15:43:41 +01:00
1 parent fabbc6d147
commit df0950ef11
30 files changed
+5457 -7

No files matched your search

+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<content url="file://$MODULE_DIR$/python" />
<orderEntry type="jdk" jdkName="~/development/modellierung-und-simulation-ue1/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
+1 -1
View File
@@ -7,7 +7,7 @@ My interpretation of them is available at docs/docs.md
An UML diagram is available at docs/uml.svg
## Web
The web implementation is pure JavaScript
The web implementation is TypeScript with React and Vite (in `web/`), served by nginx via Docker (`compose.yaml` in the root)
## Python
The python implementation is a single-file script, which can be run on the command line or a Jupyter notebook
+20 -1
View File
@@ -1,10 +1,29 @@
# Modellierung und Simulation Übung 1
Dieses Reposiotry enthält zwei Projekte:
1) Im Ordner "python" die Python-Implementierung
2) Im Ordner "web" die JavaScript-Implementierung verfügbar und https://sim.teier.net/ue1
2) Im Ordner "web" die TypeScript-Implementierung, verfügbar unter https://sim.teier.net/ue1
## Python
```sh
cd python
uv run ue1.py --animation # bunt
uv run ue1.py --experiment alle # wind, schneise, trockenheit oder alle
un run ue1.py --wind 0.8 --animation # Einzellauf
```
## Web
Entwicklung:
```sh
cd web
npm install
npm run dev
```
Docker (nginx, http://localhost:8080):
```sh
docker compose up -d --build
```
Die Web-Version verwendet einen anderen Zufallsgenerator als Python: Läufe mit demselben Seed sind reproduzierbar,
stimmen aber nicht mit den Python-Läufen überein.
+7
View File
@@ -0,0 +1,7 @@
services:
web:
build: ./web
image: waldbrand-simulation
ports:
- "8080:80"
restart: unless-stopped
+1
View File
@@ -26,6 +26,7 @@ Windfaktor für Verbreitung:
Entzündungswahrscheinlichkeit eine Zelle:
Distanz * Brandfaktor * Windfaktor
Rand: Der gesamte Simulationsrand ist immer eine Brandschneise
Code: https://git.teier.net/teier/modellierung-und-simulation-ue1.git
Link: https://sim.teier.net/ue1
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Do not edit this file with editors other than draw.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" style="background: #ffffff; background-color: var(--ge-adaptive-bg, #ffffff); color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1252px" height="1172px" viewBox="0 0 1252 1172" id="ge-svg-Y9A-iCAgniLj27S3n44-" content="&lt;mxfile host=&quot;app.diagrams.net&quot; scale=&quot;1&quot; border=&quot;0&quot;&gt;&#10; &lt;diagram name=&quot;Page-1&quot; id=&quot;ybR0R8uh0gsWY412Z8hE&quot;&gt;&#10; &lt;mxGraphModel dx=&quot;1291&quot; dy=&quot;2434&quot; grid=&quot;1&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;827&quot; pageHeight=&quot;1169&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;&#10; &lt;root&gt;&#10; &lt;mxCell id=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-1&quot; parent=&quot;1&quot; style=&quot;ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;&quot; value=&quot;&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;30&quot; width=&quot;30&quot; x=&quot;372.5&quot; y=&quot;-90&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-2&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-1&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;html=1;verticalAlign=bottom;endArrow=open;endSize=8;strokeColor=#ff0000;rounded=0;fontSize=12;defaultLabelBackgroundColor=page;curved=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-59&quot; value=&quot;&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;mxPoint x=&quot;255&quot; y=&quot;280&quot; as=&quot;targetPoint&quot; /&gt;&#10; &lt;/mxGeometry&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-7&quot; parent=&quot;1&quot; style=&quot;shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;&quot; value=&quot;Für jede Zelle&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;700&quot; width=&quot;1130&quot; x=&quot;180&quot; y=&quot;150&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-25&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-24&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-54&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;entryX=0.5;entryY=0;entryDx=0;entryDy=0;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-64&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;mxPoint x=&quot;255&quot; y=&quot;770&quot; as=&quot;targetPoint&quot; /&gt;&#10; &lt;/mxGeometry&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; parent=&quot;1&quot; style=&quot;rhombus;whiteSpace=wrap;html=1;shapeInside=1;&quot; value=&quot;Zellenzustand&amp;lt;div&amp;gt;ist brennend?&amp;lt;/div&amp;gt;&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;120&quot; width=&quot;120&quot; x=&quot;195&quot; y=&quot;280&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-20&quot; parent=&quot;1&quot; style=&quot;shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=100;height=40;&quot; value=&quot;Für jeden Moore-Nachbarn&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;540&quot; width=&quot;440&quot; x=&quot;827&quot; y=&quot;260&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-40&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-22&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-64&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;Array as=&quot;points&quot;&gt;&#10; &lt;mxPoint x=&quot;710&quot; y=&quot;680&quot; Line truncated
<svg xmlns="http://www.w3.org/2000/svg" style="background: #ffffff; background-color: var(--ge-adaptive-bg, #ffffff); color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1252px" height="1562px" viewBox="0 0 1252 1562" id="ge-svg-k-QJNAvAxnYBbfuaatij" content="&lt;mxfile host=&quot;app.diagrams.net&quot; scale=&quot;1&quot; border=&quot;0&quot;&gt;&#10; &lt;diagram name=&quot;Page-1&quot; id=&quot;ybR0R8uh0gsWY412Z8hE&quot;&gt;&#10; &lt;mxGraphModel dx=&quot;1291&quot; dy=&quot;2434&quot; grid=&quot;1&quot; gridSize=&quot;10&quot; guides=&quot;1&quot; tooltips=&quot;1&quot; connect=&quot;1&quot; arrows=&quot;1&quot; fold=&quot;1&quot; page=&quot;1&quot; pageScale=&quot;1&quot; pageWidth=&quot;827&quot; pageHeight=&quot;1169&quot; math=&quot;0&quot; shadow=&quot;0&quot;&gt;&#10; &lt;root&gt;&#10; &lt;mxCell id=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;1&quot; parent=&quot;0&quot; /&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-1&quot; parent=&quot;1&quot; style=&quot;ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;&quot; value=&quot;&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;30&quot; width=&quot;30&quot; x=&quot;372.5&quot; y=&quot;-90&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-2&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-1&quot; style=&quot;edgeStyle=orthogonalEdgeStyle;html=1;verticalAlign=bottom;endArrow=open;endSize=8;strokeColor=#ff0000;rounded=0;fontSize=12;defaultLabelBackgroundColor=page;curved=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-59&quot; value=&quot;&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;mxPoint x=&quot;255&quot; y=&quot;280&quot; as=&quot;targetPoint&quot; /&gt;&#10; &lt;/mxGeometry&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-7&quot; parent=&quot;1&quot; style=&quot;shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=70;height=70;&quot; value=&quot;Für jede Zelle im alten Zustand&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;700&quot; width=&quot;1130&quot; x=&quot;190&quot; y=&quot;330&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-25&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;&quot; target=&quot;vpmAtYMeYcpatCYC-Gde-24&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-54&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;entryX=0.5;entryY=0;entryDx=0;entryDy=0;&quot; target=&quot;EYQY27FIURCIZYL1lPWE-6&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;mxPoint x=&quot;265&quot; y=&quot;950&quot; as=&quot;targetPoint&quot; /&gt;&#10; &lt;/mxGeometry&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-8&quot; parent=&quot;1&quot; style=&quot;rhombus;whiteSpace=wrap;html=1;shapeInside=1;&quot; value=&quot;Zellenzustand&amp;lt;div&amp;gt;ist brennend?&amp;lt;/div&amp;gt;&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;120&quot; width=&quot;120&quot; x=&quot;205&quot; y=&quot;460&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-20&quot; parent=&quot;1&quot; style=&quot;shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=103;height=50;&quot; value=&quot;Für jeden Moore-Nachbarn im alten Zustand&quot; vertex=&quot;1&quot;&gt;&#10; &lt;mxGeometry height=&quot;540&quot; width=&quot;440&quot; x=&quot;837&quot; y=&quot;440&quot; as=&quot;geometry&quot; /&gt;&#10; &lt;/mxCell&gt;&#10; &lt;mxCell id=&quot;vpmAtYMeYcpatCYC-Gde-40&quot; edge=&quot;1&quot; parent=&quot;1&quot; source=&quot;vpmAtYMeYcpatCYC-Gde-22&quot; style=&quot;edgeStyle=none;curved=1;rounded=0;curveGeometry=1;orthogonalLoop=1;jettySize=auto;html=1;fontSize=12;defaultLabelBackgroundColor=page;startSize=8;endSize=8;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=0.25;entryDx=0;entryDy=0;&quot; target=&quot;EYQY27FIURCIZYL1lPWE-6&quot;&gt;&#10; &lt;mxGeometry relative=&quot;1&quot; as=&quot;geometry&quot;&gt;&#10; &lt;Array Line truncated

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 425 KiB

+4 -1
View File
@@ -2,4 +2,7 @@
name = "modellierung-und-simulation-ue1"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = []
dependencies = [
"matplotlib>=3.5",
"numpy>=1.22",
]
+413
View File
@@ -1 +1,414 @@
"""Waldbrand als zellulärer Automat (Modellierung und Simulation, UE1).
Kommandozeile:
python ue1.py --experiment alle
python ue1.py --experiment wind --laeufe 30 --seed 1
python ue1.py --animation
Jupyter / Colab:
%run -n ue1.py (oder: from ue1 import *)
ergebnis = simuliere(Params(wind_staerke=0.8, seed=1))
zeige_verlauf(ergebnis)
experiment_wind()
Modellregeln (Kurzfassung):
- Zustände: Wald, brennend, abgebrannt, gelöscht, Brandschneise.
Nur Wald kann sich entzünden; abgebrannt, gelöscht und Brandschneise verhalten sich gleich.
- Nachbarschaft: Moore (8 Zellen), Distanzgewicht 1 (direkt) bzw. 0.7 (diagonal).
- Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise, das Feuer kann das Gebiet
also nicht verlassen. Jede innere Zelle hat damit iåmmer 8 Nachbarn.
- Brandherd: eine zufällige Waldzelle (durch den Seed festgelegt) oder eine vorgegebene Zelle.
- Synchrones Update: alle Regeln lesen den Zustand zum Zeitpunkt t und schreiben t+1.
- Pro Zeitschritt, für jede brennende Zelle:
1. Mit Löschwahrscheinlichkeit wird sie gelöscht (und breitet sich nicht mehr aus).
2. Sonst versucht sie jeden Nachbarn, der Wald ist, zu entzünden (unabhängige Versuche):
p = 0.5 * Distanzgewicht * Brandfaktor(Nachbar) * Windfaktor
Windfaktor = 1 + Windstärke * cos(Winkel(Windvektor, Vektor brennende Zelle -> Nachbar))
3. Danach Restbrenndauer - 1; bei 0 wird sie abgebrannt.
Eine Zelle mit Restbrenndauer d breitet sich also in d Zeitschritten aus.
- Windvektor (x, y): x zeigt nach rechts (Osten), y nach oben (Norden),
der Vektor gibt die Richtung an, in die der Wind weht.
"""
import argparse
import math
from dataclasses import dataclass, field, replace
from typing import Dict, List, Optional, Sequence, Tuple
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch
# Zustände
WALD = 0
BRENNEND = 1
ABGEBRANNT = 2
GELOESCHT = 3
SCHNEISE = 4
ZUSTAND_NAMEN = ["Wald", "brennend", "abgebrannt", "gelöscht", "Brandschneise"]
ZUSTAND_FARBEN = ["#008300", "#e34948", "#3a3a38", "#86b6ef", "#d9d4c7"]
ZUSTAND_CMAP = ListedColormap(ZUSTAND_FARBEN)
# Farben für Varianten in Vergleichsplots (feste Reihenfolge)
VARIANTEN_FARBEN = ["#2a78d6", "#eb6834", "#1baf7a"]
# Moore-Nachbarschaft: (Zeilenversatz, Spaltenversatz, Distanzgewicht)
NACHBARN = [
(-1, -1, 0.7), (-1, 0, 1.0), (-1, 1, 0.7),
(0, -1, 1.0), (0, 1, 1.0),
(1, -1, 0.7), (1, 0, 1.0), (1, 1, 0.7),
]
@dataclass
class Params:
breite: int = 100
hoehe: int = 100
# Anteil natürlicher Brandschneisen (z.B. Wasser, Fels), zufällig verteilt
schneise_anteil: float = 0.05
# Künstliche Brandschneisen als volle Zeilen bzw. Spalten
schneisen_zeilen: Sequence[int] = ()
schneisen_spalten: Sequence[int] = ()
# Brandfaktor ("Trockenheit") je Waldzelle, gleichverteilt
brandfaktor_min: float = 0.1
brandfaktor_max: float = 1.0
# Restbrenndauer je Waldzelle, ganzzahlig gleichverteilt (inklusive Grenzen)
brenndauer_min: int = 2
brenndauer_max: int = 5
wind_richtung: Tuple[float, float] = (1.0, 0.0)
wind_staerke: float = 0.0
loesch_wahrscheinlichkeit: float = 0.05
# Startzelle (Zeile, Spalte), None = zufällige Waldzelle
start: Optional[Tuple[int, int]] = None
max_schritte: int = 1000
seed: Optional[int] = None
@dataclass
class Ergebnis:
params: Params
verlauf: List[np.ndarray] # Zustandsgitter je Zeitschritt, verlauf[0] = Anfangszustand
anzahl: np.ndarray # Form (Schritte + 1, 5): Anzahl Zellen je Zustand und Zeitschritt
wald_anfang: int = field(init=False)
def __post_init__(self):
self.wald_anfang = int(self.anzahl[0, WALD] + self.anzahl[0, BRENNEND])
@property
def dauer(self) -> int:
return len(self.verlauf) - 1
@property
def betroffen(self) -> int:
"""Zellen, die irgendwann gebrannt haben."""
letzte = self.anzahl[-1]
return int(letzte[BRENNEND] + letzte[ABGEBRANNT] + letzte[GELOESCHT])
@property
def betroffen_anteil(self) -> float:
"""Anteil der anfänglichen Waldfläche, die gebrannt hat."""
return self.betroffen / self.wald_anfang
def windfaktor(dr: int, dc: int, richtung: Tuple[float, float], staerke: float) -> float:
"""1 + Windstärke * cos(Winkel zwischen Windvektor und Vektor zur Nachbarzelle)."""
wx, wy = richtung
norm_wind = math.hypot(wx, wy)
if staerke == 0 or norm_wind == 0:
return 1.0
# Zeilenindex wächst nach unten, y zeigt nach oben
nx, ny = dc, -dr
cos_winkel = (wx * nx + wy * ny) / (norm_wind * math.hypot(nx, ny))
return 1 + staerke * cos_winkel
def verschiebe(a: np.ndarray, dr: int, dc: int) -> np.ndarray:
"""Liefert b mit b[r, c] = a[r - dr, c - dc]; außerhalb des Gitters 0 (kein Umlauf wie bei np.roll)."""
b = np.zeros_like(a)
h, w = a.shape
b[max(dr, 0):h + min(dr, 0), max(dc, 0):w + min(dc, 0)] = \
a[max(-dr, 0):h + min(-dr, 0), max(-dc, 0):w + min(-dc, 0)]
return b
def init_gitter(params: Params, rng: np.random.Generator):
"""Erzeugt Zustand, Brandfaktor und Restbrenndauer und entzündet die Startzelle."""
form = (params.hoehe, params.breite)
zustand = np.where(rng.random(form) < params.schneise_anteil, SCHNEISE, WALD).astype(np.int8)
if params.start is not None:
# Am vorgegebenen Brandherd wächst immer Wald, auch wenn dort zufällig eine natürliche Schneise läge
zustand[params.start] = WALD
for zeile in params.schneisen_zeilen:
zustand[zeile, :] = SCHNEISE
for spalte in params.schneisen_spalten:
zustand[:, spalte] = SCHNEISE
# Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise
zustand[[0, -1], :] = SCHNEISE
zustand[:, [0, -1]] = SCHNEISE
if params.start is None:
# Zufällige Waldzelle; hängt nur vom Seed und den Zelltypen ab, daher bei gleichem Seed
# in allen Varianten dieselbe, solange diese die Zelltypen nicht verändern
waldzellen = np.argwhere(zustand == WALD)
start = tuple(waldzellen[rng.integers(len(waldzellen))])
else:
start = params.start
if zustand[start] != WALD:
raise ValueError(f"Startzelle {start} liegt auf einer künstlichen Brandschneise oder am Rand")
brandfaktor = rng.uniform(params.brandfaktor_min, params.brandfaktor_max, form)
restbrenndauer = rng.integers(params.brenndauer_min, params.brenndauer_max + 1, form)
brandfaktor[zustand == SCHNEISE] = 0
restbrenndauer[zustand == SCHNEISE] = 0
zustand[start] = BRENNEND
return zustand, brandfaktor, restbrenndauer
def schritt(zustand, brandfaktor, restbrenndauer, params: Params, rng: np.random.Generator):
"""Ein synchroner Zeitschritt; die Eingabe-Arrays werden nicht verändert."""
brennend = zustand == BRENNEND
wald = zustand == WALD
geloescht = brennend & (rng.random(zustand.shape) < params.loesch_wahrscheinlichkeit)
ausbreitend = brennend & ~geloescht
entzuendet = np.zeros_like(wald)
for dr, dc, gewicht in NACHBARN:
# Zielzelle (r, c) hat eine ausbreitende Nachbarzelle bei (r - dr, c - dc)
quelle = verschiebe(ausbreitend, dr, dc)
p = 0.5 * gewicht * brandfaktor * windfaktor(dr, dc, params.wind_richtung, params.wind_staerke)
entzuendet |= wald & quelle & (rng.random(zustand.shape) < p)
neu_zustand = zustand.copy()
neu_brandfaktor = brandfaktor.copy()
neu_restbrenndauer = restbrenndauer.copy()
neu_restbrenndauer[ausbreitend] -= 1
ausgebrannt = ausbreitend & (neu_restbrenndauer == 0)
neu_zustand[ausgebrannt] = ABGEBRANNT
neu_zustand[geloescht] = GELOESCHT
neu_restbrenndauer[geloescht] = 0
neu_brandfaktor[ausgebrannt | geloescht] = 0
neu_zustand[entzuendet] = BRENNEND
return neu_zustand, neu_brandfaktor, neu_restbrenndauer
def zaehle(zustand: np.ndarray) -> np.ndarray:
return np.bincount(zustand.ravel(), minlength=len(ZUSTAND_NAMEN))
def simuliere(params: Params) -> Ergebnis:
"""Simuliert, bis keine Zelle mehr brennt oder max_schritte erreicht ist."""
rng = np.random.default_rng(params.seed)
zustand, brandfaktor, restbrenndauer = init_gitter(params, rng)
verlauf = [zustand]
anzahl = [zaehle(zustand)]
while anzahl[-1][BRENNEND] > 0 and len(verlauf) <= params.max_schritte:
zustand, brandfaktor, restbrenndauer = schritt(zustand, brandfaktor, restbrenndauer, params, rng)
verlauf.append(zustand)
anzahl.append(zaehle(zustand))
return Ergebnis(params, verlauf, np.array(anzahl))
# ---------------------------------------------------------------------------
# Visualisierung
# ---------------------------------------------------------------------------
def zeige_gitter(zustand: np.ndarray, ax=None, titel: str = ""):
if ax is None:
_, ax = plt.subplots(figsize=(5, 5))
ax.imshow(zustand, cmap=ZUSTAND_CMAP, vmin=0, vmax=len(ZUSTAND_NAMEN) - 1, interpolation="nearest")
ax.set_title(titel, fontsize=10)
ax.set_xticks([])
ax.set_yticks([])
return ax
def zustand_legende(fig):
griffe = [Patch(color=farbe, label=name) for name, farbe in zip(ZUSTAND_NAMEN, ZUSTAND_FARBEN)]
fig.legend(handles=griffe, loc="lower center", ncol=len(griffe), frameon=False, fontsize=9)
def zeichne_kurven(ax, ergebnis: Ergebnis, farbe: str, name: Optional[str] = None):
"""Betroffene Fläche über die Zeit; ohne Name zusätzlich die brennenden Zellen (gestrichelt)."""
a = ergebnis.anzahl
betroffen = (a[:, BRENNEND] + a[:, ABGEBRANNT] + a[:, GELOESCHT]) / ergebnis.wald_anfang * 100
ax.plot(betroffen, color=farbe, linewidth=2, label=name or "betroffen gesamt")
if name is None:
brennend = a[:, BRENNEND] / ergebnis.wald_anfang * 100
ax.plot(brennend, color=farbe, linewidth=2, linestyle="--", label="brennend")
def kurven_achse(ax):
ax.set_xlabel("Zeitschritt")
ax.set_ylabel("% der Waldfläche")
ax.set_ylim(0, 100)
ax.grid(alpha=0.3)
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False, fontsize=9)
def zeige_verlauf(ergebnis: Ergebnis, anzahl_bilder: int = 5):
"""Momentaufnahmen des Gitters und Zeitverlauf eines einzelnen Laufs."""
zeitpunkte = np.linspace(0, ergebnis.dauer, anzahl_bilder).astype(int)
fig = plt.figure(figsize=(3 * anzahl_bilder, 6.5))
raster = fig.add_gridspec(2, anzahl_bilder, height_ratios=[1, 0.8])
for i, t in enumerate(zeitpunkte):
zeige_gitter(ergebnis.verlauf[t], fig.add_subplot(raster[0, i]), f"t = {t}")
ax = fig.add_subplot(raster[1, :])
zeichne_kurven(ax, ergebnis, VARIANTEN_FARBEN[0])
kurven_achse(ax)
fig.suptitle(f"Betroffen: {ergebnis.betroffen_anteil:.0%} der Waldfläche, Dauer: {ergebnis.dauer} Schritte")
zustand_legende(fig)
fig.tight_layout(rect=(0, 0.05, 1, 1))
return fig
def zeige_vergleich(ergebnisse: Dict[str, Ergebnis]):
"""Endzustand jeder Variante nebeneinander plus gemeinsamer Zeitverlauf."""
n = len(ergebnisse)
fig = plt.figure(figsize=(4 * n + 5, 4.8))
raster = fig.add_gridspec(1, n + 1, width_ratios=[1] * n + [1.4])
ax_kurven = fig.add_subplot(raster[0, n])
for i, (name, ergebnis) in enumerate(ergebnisse.items()):
titel = f"{name}\nbetroffen {ergebnis.betroffen_anteil:.0%}, {ergebnis.dauer} Schritte"
zeige_gitter(ergebnis.verlauf[-1], fig.add_subplot(raster[0, i]), titel)
zeichne_kurven(ax_kurven, ergebnis, VARIANTEN_FARBEN[i % len(VARIANTEN_FARBEN)], name)
kurven_achse(ax_kurven)
zustand_legende(fig)
fig.tight_layout(rect=(0, 0.07, 1, 1))
return fig
def animiere(ergebnis: Ergebnis, intervall_ms: int = 80):
"""Animation eines Laufs. In Jupyter: from IPython.display import HTML; HTML(anim.to_jshtml())"""
fig, ax = plt.subplots(figsize=(6, 6.4))
bild = ax.imshow(ergebnis.verlauf[0], cmap=ZUSTAND_CMAP, vmin=0, vmax=len(ZUSTAND_NAMEN) - 1,
interpolation="nearest")
ax.set_xticks([])
ax.set_yticks([])
zustand_legende(fig)
def update(t):
bild.set_data(ergebnis.verlauf[t])
ax.set_title(f"t = {t}")
return (bild,)
anim = FuncAnimation(fig, update, frames=len(ergebnis.verlauf), interval=intervall_ms)
return anim
# ---------------------------------------------------------------------------
# Experimente
# ---------------------------------------------------------------------------
def vergleiche(varianten: Dict[str, Params], laeufe: int = 20, seed: int = 0, zeigen: bool = True):
"""Simuliert jede Variante mit denselben Seeds und gibt Mittelwerte aus.
Gleiche Seeds bedeuten: alle Varianten starten mit demselben Wald und Brandherd (gleiche Brandfaktoren,
Brenndauern und natürlichen Schneisen), soweit die Variante die Initialisierung nicht selbst ändert.
"""
statistik = {}
beispiele = {}
for name, params in varianten.items():
ergebnisse = [simuliere(replace(params, seed=seed + i)) for i in range(laeufe)]
anteile = np.array([e.betroffen_anteil for e in ergebnisse])
dauern = np.array([e.dauer for e in ergebnisse])
statistik[name] = {
"betroffen_mittel": anteile.mean(),
"betroffen_std": anteile.std(),
"dauer_mittel": dauern.mean(),
"dauer_std": dauern.std(),
"grossbrand_anteil": (anteile > 0.5).mean(),
}
# Beispiel für die Grafik: der Lauf mit dem Median der betroffenen Fläche
beispiele[name] = ergebnisse[int(np.argsort(anteile)[len(anteile) // 2])]
print(f"{'Variante':<28}{'betroffen (Mittel ± Std)':>26}{'Dauer (Mittel ± Std)':>24}{'Läufe > 50 %':>14}")
for name, s in statistik.items():
print(f"{name:<28}{s['betroffen_mittel']:>17.1%} ± {s['betroffen_std']:>5.1%}"
f"{s['dauer_mittel']:>16.1f} ± {s['dauer_std']:>5.1f}{s['grossbrand_anteil']:>14.0%}")
print()
if zeigen:
zeige_vergleich(beispiele)
return statistik, beispiele
def experiment_wind(laeufe: int = 20, seed: int = 0, zeigen: bool = True):
print("Experiment Wind: Windstärke 0 vs. 0.8 (Wind nach Osten)")
basis = Params()
return vergleiche({
"ohne Wind": basis,
"Wind 0.8 nach Osten": replace(basis, wind_richtung=(1, 0), wind_staerke=0.8),
}, laeufe, seed, zeigen)
def experiment_schneise(laeufe: int = 20, seed: int = 0, zeigen: bool = True):
print("Experiment Brandschneise: Wind nach Osten, ohne vs. mit Schneise östlich des Brandherds")
# Fester Brandherd westlich der Schneise, damit der Vergleich nicht vom zufälligen Startpunkt abhängt
basis = Params(wind_richtung=(1, 0), wind_staerke=0.8, start=(50, 50))
# Eine durchgehende gerade Schneise ist in der Moore-Nachbarschaft schon mit 1 Zelle Breite
# unüberwindbar, die Breite spielt in diesem Modell also keine Rolle
return vergleiche({
"ohne Schneise": basis,
"Schneise in Spalte 65": replace(basis, schneisen_spalten=(65,)),
}, laeufe, seed, zeigen)
def experiment_trockenheit(laeufe: int = 20, seed: int = 0, zeigen: bool = True):
print("Experiment Trockenheit: Brandfaktor feucht (0.1-0.5) vs. normal (0.1-1.0) vs. trocken (0.5-1.0)")
basis = Params()
return vergleiche({
"feucht (0.1-0.5)": replace(basis, brandfaktor_min=0.1, brandfaktor_max=0.5),
"normal (0.1-1.0)": basis,
"trocken (0.5-1.0)": replace(basis, brandfaktor_min=0.5, brandfaktor_max=1.0),
}, laeufe, seed, zeigen)
EXPERIMENTE = {
"wind": experiment_wind,
"schneise": experiment_schneise,
"trockenheit": experiment_trockenheit,
}
def main():
parser = argparse.ArgumentParser(description="Waldbrand als zellulärer Automat")
parser.add_argument("--experiment", choices=[*EXPERIMENTE, "alle"],
help="Experiment ausführen (ohne Angabe: einzelner Lauf mit Standardparametern)")
parser.add_argument("--laeufe", type=int, default=20, help="Läufe je Variante")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--wind", type=float, default=0.0, help="Windstärke für den Einzellauf")
parser.add_argument("--animation", action="store_true", help="Einzellauf animieren")
parser.add_argument("--speichern", metavar="PRAEFIX", help="Grafiken als PRAEFIX_<n>.png speichern statt anzeigen")
args = parser.parse_args()
anim = None
if args.experiment:
namen = list(EXPERIMENTE) if args.experiment == "alle" else [args.experiment]
for name in namen:
EXPERIMENTE[name](args.laeufe, args.seed)
else:
ergebnis = simuliere(Params(wind_staerke=args.wind, seed=args.seed))
if args.animation:
anim = animiere(ergebnis) # Referenz halten, sonst stoppt die Animation
else:
zeige_verlauf(ergebnis)
if args.speichern:
for i in plt.get_fignums():
plt.figure(i).savefig(f"{args.speichern}_{i}.png", dpi=150)
else:
plt.show()
return anim
if __name__ == "__main__":
main()
+1701
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
Dockerfile
.dockerignore
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+10
View File
@@ -0,0 +1,10 @@
FROM node:26-trixie-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:mainline-trixie
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Waldbrand-Simulation als zellulärer Automat" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>"
/>
<title>Waldbrand-Simulation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}
# Dateinamen in /assets enthalten einen Hash und ändern sich mit jedem Build
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
+1694
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "waldbrand-simulation",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.3.1",
"react-dom": "^19.3.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"typescript": "^7.0.2",
"vite": "^8.3.5"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from "react";
import Experimente from "./komponenten/Experimente";
import Simulation from "./komponenten/Simulation";
type Ansicht = "simulation" | "experimente";
export default function App() {
const [ansicht, setAnsicht] = useState<Ansicht>("simulation");
return (
<>
<header className="kopf">
<div>
<h1>Waldbrand-Simulation</h1>
<p>Zellulärer Automat – Modellierung und Simulation, Übung 1</p>
</div>
<nav className="reiter" role="tablist">
{(
[
["simulation", "Simulation"],
["experimente", "Experimente"],
] as const
).map(([wert, name]) => (
<button
key={wert}
role="tab"
aria-selected={ansicht === wert}
className={ansicht === wert ? "reiter__tab aktiv" : "reiter__tab"}
onClick={() => setAnsicht(wert)}
>
{name}
</button>
))}
</nav>
</header>
{/* Beide Ansichten bleiben eingehängt, damit ein laufender Brand beim Wechsel erhalten bleibt */}
<main hidden={ansicht !== "simulation"}>
<Simulation />
</main>
<main hidden={ansicht !== "experimente"}>
<Experimente />
</main>
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { simuliere, STANDARD_PARAMS, type Ergebnis, type Params } from "./modell";
export interface Experiment {
titel: string;
beschreibung: string;
varianten: Record<string, Params>;
}
const MITTE = [STANDARD_PARAMS.hoehe / 2, STANDARD_PARAMS.breite / 2] as const;
export const EXPERIMENTE = {
wind: {
titel: "Wind",
beschreibung: "Windstärke 0 vs. 0.8, Wind nach Osten.",
varianten: {
"ohne Wind": STANDARD_PARAMS,
"Wind 0.8 nach Osten": { ...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8 },
},
},
schneise: {
titel: "Brandschneise",
beschreibung:
"Wind 0.8 nach Osten, ohne vs. mit durchgehender Schneise östlich des Brandherds. " +
"Der Brandherd liegt fest in der Mitte, damit der Vergleich nicht vom zufälligen Startpunkt abhängt.",
varianten: {
"ohne Schneise": { ...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8, start: MITTE },
"Schneise in Spalte 65": {
...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8, start: MITTE, schneisenSpalten: [65],
},
},
},
trockenheit: {
titel: "Trockenheit",
beschreibung: "Brandfaktor feucht (0.1–0.5) vs. normal (0.1–1.0) vs. trocken (0.5–1.0).",
varianten: {
"feucht (0.1–0.5)": { ...STANDARD_PARAMS, brandfaktorMin: 0.1, brandfaktorMax: 0.5 },
"normal (0.1–1.0)": STANDARD_PARAMS,
"trocken (0.5–1.0)": { ...STANDARD_PARAMS, brandfaktorMin: 0.5, brandfaktorMax: 1.0 },
},
},
} satisfies Record<string, Experiment>;
export type ExperimentName = keyof typeof EXPERIMENTE;
export interface Statistik {
name: string;
betroffenMittel: number;
betroffenStd: number;
dauerMittel: number;
dauerStd: number;
grossbrandAnteil: number;
/** Lauf mit dem Median der betroffenen Fläche, als Beispiel für die Grafik */
beispiel: Ergebnis;
}
function mittelUndStd(werte: number[]): [number, number] {
const mittel = werte.reduce((a, b) => a + b, 0) / werte.length;
const varianz = werte.reduce((a, b) => a + (b - mittel) ** 2, 0) / werte.length;
return [mittel, Math.sqrt(varianz)];
}
/**
* Simuliert jede Variante mit denselben Seeds. Gleiche Seeds bedeuten: alle Varianten starten mit
* demselben Wald und Brandherd, soweit die Variante die Initialisierung nicht selbst ändert.
*/
export function vergleiche(
varianten: Record<string, Params>,
laeufe: number,
seed: number,
fortschritt?: (fertig: number, gesamt: number) => void,
): Statistik[] {
const gesamt = Object.keys(varianten).length * laeufe;
let fertig = 0;
return Object.entries(varianten).map(([name, params]) => {
const ergebnisse: Ergebnis[] = [];
for (let i = 0; i < laeufe; i++) {
ergebnisse.push(simuliere({ ...params, seed: seed + i }));
fortschritt?.(++fertig, gesamt);
}
const anteile = ergebnisse.map((e) => e.betroffenAnteil);
const [betroffenMittel, betroffenStd] = mittelUndStd(anteile);
const [dauerMittel, dauerStd] = mittelUndStd(ergebnisse.map((e) => e.dauer));
const sortiert = [...ergebnisse].sort((a, b) => a.betroffenAnteil - b.betroffenAnteil);
return {
name,
betroffenMittel,
betroffenStd,
dauerMittel,
dauerStd,
grossbrandAnteil: anteile.filter((a) => a > 0.5).length / laeufe,
beispiel: sortiert[Math.floor(laeufe / 2)],
};
});
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { betroffen, waldflaeche } from "../modell";
import { EXPERIMENTE, type ExperimentName, type Statistik } from "../experimente";
import type { Auftrag, Nachricht } from "../worker";
import Gitter from "./Gitter";
import Legende from "./Legende";
import Verlaufsdiagramm, { type Datenpunkt, type Serie } from "./Verlaufsdiagramm";
// Feste Reihenfolge: Variante i bekommt immer Farbe i
const VARIANTEN_FARBEN = ["#2a78d6", "#eb6834", "#1baf7a"];
const prozent = (wert: number) => `${(wert * 100).toFixed(1)} %`;
/** Betroffene Fläche je Variante, zusammengeführt nach Zeitschritt */
function vergleichsdaten(statistik: Statistik[]): Datenpunkt[] {
const laengste = Math.max(...statistik.map((s) => s.beispiel.dauer));
return Array.from({ length: laengste + 1 }, (_, t) => {
const punkt: Datenpunkt = { t };
statistik.forEach((s, i) => {
const anzahl = s.beispiel.anzahl[t];
if (anzahl) punkt[`v${i}`] = (betroffen(anzahl) / waldflaeche(anzahl)) * 100;
});
return punkt;
});
}
interface Auswertung {
experiment: ExperimentName;
laeufe: number;
statistik: Statistik[];
}
export default function Experimente() {
const [experiment, setExperiment] = useState<ExperimentName>("wind");
const [laeufe, setLaeufe] = useState(20);
const [seed, setSeed] = useState(0);
const [fortschritt, setFortschritt] = useState<number | null>(null);
const [auswertung, setAuswertung] = useState<Auswertung | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => () => workerRef.current?.terminate(), []);
function starten() {
workerRef.current?.terminate();
const worker = new Worker(new URL("../worker.ts", import.meta.url), { type: "module" });
workerRef.current = worker;
setFortschritt(0);
setFehler(null);
worker.onmessage = ({ data }: MessageEvent<Nachricht>) => {
if (data.typ === "fortschritt") {
setFortschritt(data.fertig / data.gesamt);
} else {
setAuswertung({ experiment, laeufe, statistik: data.statistik });
setFortschritt(null);
worker.terminate();
}
};
worker.onerror = (e) => {
setFehler(e.message);
setFortschritt(null);
};
worker.postMessage({ experiment, laeufe, seed } satisfies Auftrag);
}
const serien: Serie[] =
auswertung?.statistik.map((s, i) => ({ schluessel: `v${i}`, name: s.name, farbe: VARIANTEN_FARBEN[i] })) ?? [];
return (
<div className="experimente">
<section className="karte">
<h2>Experiment</h2>
<div className="experiment-wahl" role="radiogroup" aria-label="Experiment">
{(Object.keys(EXPERIMENTE) as ExperimentName[]).map((name) => (
<button
key={name}
role="radio"
aria-checked={experiment === name}
className={experiment === name ? "werkzeug__option aktiv" : "werkzeug__option"}
onClick={() => setExperiment(name)}
>
{EXPERIMENTE[name].titel}
</button>
))}
</div>
<p>{EXPERIMENTE[experiment].beschreibung}</p>
<p className="hinweis">
Jede Variante läuft mit denselben Seeds, startet also mit demselben Wald und Brandherd. Alle übrigen
Parameter haben ihre Standardwerte (Löschwahrscheinlichkeit 5 %, Brenndauer 2–5, 5 % natürliche Schneisen).
</p>
<div className="steuerung">
<label className="feld feld--inline">
Läufe je Variante
<input
type="number"
min={1}
max={200}
value={laeufe}
onChange={(e) => setLaeufe(Math.min(200, Math.max(1, Math.floor(Number(e.target.value)))))}
/>
</label>
<label className="feld feld--inline">
Seed
<input
type="number"
min={0}
value={seed}
onChange={(e) => setSeed(Math.max(0, Math.floor(Number(e.target.value))))}
/>
</label>
<button className="knopf knopf--primaer" onClick={starten} disabled={fortschritt !== null}>
{fortschritt !== null ? "Läuft …" : "Starten"}
</button>
</div>
{fortschritt !== null && <progress value={fortschritt} max={1} />}
{fehler && <p className="fehler">Fehler: {fehler}</p>}
</section>
{auswertung && (
<>
<section className="karte">
<h2>
Ergebnis: {EXPERIMENTE[auswertung.experiment].titel} ({auswertung.laeufe} Läufe je Variante)
</h2>
<div className="tabelle-rahmen">
<table>
<thead>
<tr>
<th>Variante</th>
<th>betroffen (Mittel ± Std)</th>
<th>Dauer in Schritten (Mittel ± Std)</th>
<th>Läufe &gt; 50 % betroffen</th>
</tr>
</thead>
<tbody>
{auswertung.statistik.map((s) => (
<tr key={s.name}>
<td>{s.name}</td>
<td>
{prozent(s.betroffenMittel)} ± {prozent(s.betroffenStd)}
</td>
<td>
{s.dauerMittel.toFixed(1)} ± {s.dauerStd.toFixed(1)}
</td>
<td>{Math.round(s.grossbrandAnteil * 100)} %</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="karte">
<h2>Typischer Lauf je Variante</h2>
<p className="hinweis">Endzustand des Laufs mit dem Median der betroffenen Fläche.</p>
<div className="beispiele">
{auswertung.statistik.map((s) => (
<figure key={s.name}>
<Gitter
breite={s.beispiel.breite}
hoehe={s.beispiel.hoehe}
zustand={s.beispiel.endzustand}
beschriftung={`Endzustand ${s.name}`}
/>
<figcaption>
<strong>{s.name}</strong>
<br />
betroffen {prozent(s.beispiel.betroffenAnteil)}, {s.beispiel.dauer} Schritte
</figcaption>
</figure>
))}
</div>
<Legende />
<h3>Betroffene Fläche über die Zeit</h3>
<Verlaufsdiagramm daten={vergleichsdaten(auswertung.statistik)} serien={serien} />
</section>
</>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, type PointerEvent } from "react";
import { ZUSTAENDE, type Zelle } from "../modell";
// Zustandsfarben als RGB, einmal vorberechnet
const FARBEN_RGB = ZUSTAENDE.map(({ farbe }) => [1, 3, 5].map((i) => parseInt(farbe.slice(i, i + 2), 16)));
interface Props {
breite: number;
hoehe: number;
zustand: Uint8Array;
/** Erhöhen, um neu zu zeichnen, wenn das Gitter direkt verändert wurde */
version?: number;
/** Wird beim Klicken und Ziehen mit der Zelle unter dem Zeiger aufgerufen */
onZelle?: (zelle: Zelle) => void;
beschriftung: string;
}
export default function Gitter({ breite, hoehe, zustand, version, onZelle, beschriftung }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const maltRef = useRef(false);
useEffect(() => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const bild = ctx.createImageData(breite, hoehe);
for (let i = 0; i < zustand.length; i++) {
const [r, g, b] = FARBEN_RGB[zustand[i]];
bild.data[4 * i] = r;
bild.data[4 * i + 1] = g;
bild.data[4 * i + 2] = b;
bild.data[4 * i + 3] = 255;
}
ctx.putImageData(bild, 0, 0);
}, [breite, hoehe, zustand, version]);
function zelleUnterZeiger(e: PointerEvent<HTMLCanvasElement>): Zelle {
const rechteck = e.currentTarget.getBoundingClientRect();
return [
Math.floor(((e.clientY - rechteck.top) / rechteck.height) * hoehe),
Math.floor(((e.clientX - rechteck.left) / rechteck.width) * breite),
];
}
return (
<canvas
ref={canvasRef}
className={onZelle ? "gitter gitter--interaktiv" : "gitter"}
width={breite}
height={hoehe}
style={{ aspectRatio: `${breite} / ${hoehe}` }}
role="img"
aria-label={beschriftung}
onPointerDown={(e) => {
if (!onZelle) return;
maltRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
onZelle(zelleUnterZeiger(e));
}}
onPointerMove={(e) => {
if (onZelle && maltRef.current) onZelle(zelleUnterZeiger(e));
}}
onPointerUp={() => (maltRef.current = false)}
onPointerCancel={() => (maltRef.current = false)}
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ZUSTAENDE } from "../modell";
export default function Legende() {
return (
<ul className="legende">
{ZUSTAENDE.map(({ name, farbe }) => (
<li key={name}>
<span className="legende__farbe" style={{ background: farbe }} />
{name}
</li>
))}
</ul>
);
}
+28
View File
@@ -0,0 +1,28 @@
interface Props {
name: string;
wert: number;
min: number;
max: number;
schritt: number;
format?: (wert: number) => string;
onChange: (wert: number) => void;
}
export default function Regler({ name, wert, min, max, schritt, format = String, onChange }: Props) {
return (
<label className="regler">
<span className="regler__kopf">
<span>{name}</span>
<output>{format(wert)}</output>
</span>
<input
type="range"
min={min}
max={max}
step={schritt}
value={wert}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
BRENNEND,
betroffen,
entzuende,
initGitter,
schritt,
setzeSchneise,
STANDARD_PARAMS,
waldflaeche,
zaehle,
zufallsgenerator,
type Gitter as GitterDaten,
type Params,
type Zelle,
type Zufall,
} from "../modell";
import Gitter from "./Gitter";
import Legende from "./Legende";
import Regler from "./Regler";
import Verlaufsdiagramm, { type Datenpunkt, type Serie } from "./Verlaufsdiagramm";
const WINDRICHTUNGEN: { name: string; vektor: readonly [number, number] }[] = [
{ name: "→ nach Osten", vektor: [1, 0] },
{ name: "↗ nach Nordosten", vektor: [1, 1] },
{ name: "↑ nach Norden", vektor: [0, 1] },
{ name: "↖ nach Nordwesten", vektor: [-1, 1] },
{ name: "← nach Westen", vektor: [-1, 0] },
{ name: "↙ nach Südwesten", vektor: [-1, -1] },
{ name: "↓ nach Süden", vektor: [0, -1] },
{ name: "↘ nach Südosten", vektor: [1, -1] },
];
const GROESSEN = [50, 100, 150, 200];
const SERIEN: Serie[] = [
{ schluessel: "betroffen", name: "betroffen gesamt", farbe: "#2a78d6" },
{ schluessel: "brennend", name: "brennend", farbe: "#eb6834" },
];
type Werkzeug = "schneise" | "feuer";
interface Lauf {
gitter: GitterDaten;
zufall: Zufall;
t: number;
}
function messpunkt(t: number, gitter: GitterDaten): Datenpunkt {
const anzahl = zaehle(gitter.zustand);
const flaeche = waldflaeche(anzahl);
return {
t,
betroffen: (betroffen(anzahl) / flaeche) * 100,
brennend: (anzahl[BRENNEND] / flaeche) * 100,
};
}
const prozent = (wert: number) => `${Math.round(wert * 100)} %`;
export default function Simulation() {
const [params, setParams] = useState<Params>({ ...STANDARD_PARAMS, seed: 1 });
const [laeuft, setLaeuft] = useState(false);
const [tempo, setTempo] = useState(20);
const [werkzeug, setWerkzeug] = useState<Werkzeug>("schneise");
const [verlauf, setVerlauf] = useState<Datenpunkt[]>([]);
const [version, setVersion] = useState(0);
const laufRef = useRef<Lauf | null>(null);
// Wind und Löschwahrscheinlichkeit wirken sofort, auch während die Simulation läuft
const paramsRef = useRef(params);
paramsRef.current = params;
function aendere<K extends keyof Params>(schluessel: K, wert: Params[K]) {
setParams((p) => ({ ...p, [schluessel]: wert }));
}
// Änderungen an Parametern, die die Initialisierung betreffen, erzeugen einen neuen Wald
const { breite, hoehe, schneiseAnteil, brandfaktorMin, brandfaktorMax, brenndauerMin, brenndauerMax, seed } = params;
const neuStarten = useCallback(() => {
const zufall = zufallsgenerator(paramsRef.current.seed);
const gitter = initGitter(paramsRef.current, zufall);
laufRef.current = { gitter, zufall, t: 0 };
setVerlauf([messpunkt(0, gitter)]);
setVersion((v) => v + 1);
setLaeuft(false);
}, []);
useEffect(neuStarten, [
neuStarten, breite, hoehe, schneiseAnteil, brandfaktorMin, brandfaktorMax, brenndauerMin, brenndauerMax, seed,
]);
const einSchritt = useCallback(() => {
const lauf = laufRef.current;
if (!lauf) return;
lauf.gitter = schritt(lauf.gitter, paramsRef.current, lauf.zufall);
lauf.t += 1;
const punkt = messpunkt(lauf.t, lauf.gitter);
setVerlauf((v) => [...v, punkt]);
setVersion((v) => v + 1);
if (punkt.brennend === 0) setLaeuft(false);
}, []);
useEffect(() => {
if (!laeuft) return;
const id = setInterval(einSchritt, 1000 / tempo);
return () => clearInterval(id);
}, [laeuft, tempo, einSchritt]);
const onZelle = useCallback(
(zelle: Zelle) => {
const lauf = laufRef.current;
if (!lauf) return;
const geaendert = werkzeug === "schneise" ? setzeSchneise(lauf.gitter, zelle) : entzuende(lauf.gitter, zelle);
if (!geaendert) return;
const punkt = messpunkt(lauf.t, lauf.gitter);
setVerlauf((v) => [...v.slice(0, -1), punkt]);
setVersion((v) => v + 1);
},
[werkzeug],
);
const lauf = laufRef.current;
const aktuell = verlauf[verlauf.length - 1];
const brennt = aktuell !== undefined && aktuell.brennend > 0;
const brennendeZellen = lauf ? zaehle(lauf.gitter.zustand)[BRENNEND] : 0;
const status = laeuft ? "läuft" : !brennt ? "beendet" : lauf?.t === 0 ? "bereit" : "pausiert";
const windIndex = WINDRICHTUNGEN.findIndex(
({ vektor }) => vektor[0] === params.windRichtung[0] && vektor[1] === params.windRichtung[1],
);
return (
<div className="simulation">
<section className="karte simulation__gitter">
{lauf && (
<Gitter
breite={lauf.gitter.breite}
hoehe={lauf.gitter.hoehe}
zustand={lauf.gitter.zustand}
version={version}
onZelle={onZelle}
beschriftung={`Waldgitter nach ${lauf.t} Zeitschritten`}
/>
)}
<Legende />
<div className="steuerung">
<button className="knopf knopf--primaer" onClick={() => setLaeuft(!laeuft)} disabled={!brennt}>
{laeuft ? "Pause" : "Start"}
</button>
<button className="knopf" onClick={einSchritt} disabled={laeuft || !brennt}>
Schritt
</button>
<button className="knopf" onClick={neuStarten}>
Neu
</button>
<div className="werkzeug" role="radiogroup" aria-label="Werkzeug">
{(
[
["schneise", "Schneise zeichnen"],
["feuer", "Feuer legen"],
] as const
).map(([wert, name]) => (
<button
key={wert}
role="radio"
aria-checked={werkzeug === wert}
className={werkzeug === wert ? "werkzeug__option aktiv" : "werkzeug__option"}
onClick={() => setWerkzeug(wert)}
>
{name}
</button>
))}
</div>
</div>
<dl className="kennzahlen">
<div>
<dt>Zeitschritt</dt>
<dd>{lauf?.t ?? 0}</dd>
</div>
<div>
<dt>betroffen</dt>
<dd>{aktuell ? aktuell.betroffen.toFixed(1) : 0} %</dd>
</div>
<div>
<dt>brennend</dt>
<dd>{brennendeZellen === 1 ? "1 Zelle" : `${brennendeZellen} Zellen`}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{status}</dd>
</div>
</dl>
<p className="hinweis">
Auf dem Gitter klicken oder ziehen, um Brandschneisen zu zeichnen oder neue Brandherde zu legen.
</p>
</section>
<section className="karte simulation__parameter">
<h2>Parameter</h2>
<div className="feld">
<label htmlFor="groesse">Gittergröße</label>
<select
id="groesse"
value={params.breite}
onChange={(e) => setParams((p) => ({ ...p, breite: Number(e.target.value), hoehe: Number(e.target.value) }))}
>
{GROESSEN.map((g) => (
<option key={g} value={g}>
{g} × {g}
</option>
))}
</select>
</div>
<div className="feld">
<label htmlFor="seed">Seed</label>
<div className="feld__zeile">
<input
id="seed"
type="number"
min={0}
value={params.seed}
onChange={(e) => aendere("seed", Math.max(0, Math.floor(Number(e.target.value))))}
/>
<button className="knopf" onClick={() => aendere("seed", Math.floor(Math.random() * 1_000_000))}>
Zufällig
</button>
</div>
</div>
<h3>Wald</h3>
<Regler
name="Natürliche Schneisen"
wert={params.schneiseAnteil}
min={0}
max={0.4}
schritt={0.01}
format={prozent}
onChange={(w) => aendere("schneiseAnteil", w)}
/>
<Regler
name="Brandfaktor min"
wert={params.brandfaktorMin}
min={0.1}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => setParams((p) => ({ ...p, brandfaktorMin: w, brandfaktorMax: Math.max(w, p.brandfaktorMax) }))}
/>
<Regler
name="Brandfaktor max"
wert={params.brandfaktorMax}
min={0.1}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => setParams((p) => ({ ...p, brandfaktorMax: w, brandfaktorMin: Math.min(w, p.brandfaktorMin) }))}
/>
<Regler
name="Brenndauer min"
wert={params.brenndauerMin}
min={1}
max={10}
schritt={1}
onChange={(w) => setParams((p) => ({ ...p, brenndauerMin: w, brenndauerMax: Math.max(w, p.brenndauerMax) }))}
/>
<Regler
name="Brenndauer max"
wert={params.brenndauerMax}
min={1}
max={10}
schritt={1}
onChange={(w) => setParams((p) => ({ ...p, brenndauerMax: w, brenndauerMin: Math.min(w, p.brenndauerMin) }))}
/>
<h3>Während der Simulation änderbar</h3>
<div className="feld">
<label htmlFor="windrichtung">Windrichtung</label>
<select
id="windrichtung"
value={windIndex}
onChange={(e) => aendere("windRichtung", WINDRICHTUNGEN[Number(e.target.value)].vektor)}
>
{WINDRICHTUNGEN.map(({ name }, i) => (
<option key={name} value={i}>
{name}
</option>
))}
</select>
</div>
<Regler
name="Windstärke"
wert={params.windStaerke}
min={0}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => aendere("windStaerke", w)}
/>
<Regler
name="Löschwahrscheinlichkeit"
wert={params.loeschWahrscheinlichkeit}
min={0}
max={0.5}
schritt={0.01}
format={prozent}
onChange={(w) => aendere("loeschWahrscheinlichkeit", w)}
/>
<Regler
name="Tempo"
wert={tempo}
min={1}
max={60}
schritt={1}
format={(w) => `${w} Schritte/s`}
onChange={setTempo}
/>
</section>
<section className="karte simulation__verlauf">
<h2>Verlauf</h2>
<Verlaufsdiagramm daten={verlauf} serien={SERIEN} />
</section>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export interface Serie {
schluessel: string;
name: string;
farbe: string;
gestrichelt?: boolean;
}
export type Datenpunkt = { t: number } & Record<string, number>;
interface Props {
daten: Datenpunkt[];
serien: Serie[];
}
/** Prozent der Waldfläche über die Zeitschritte */
export default function Verlaufsdiagramm({ daten, serien }: Props) {
return (
<div className="diagramm">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={daten} margin={{ top: 8, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" type="number" domain={[0, "dataMax"]} allowDecimals={false} tickLine={false} />
<YAxis domain={[0, 100]} unit=" %" width={52} tickLine={false} axisLine={false} />
<Tooltip
formatter={(wert) => `${Number(wert).toFixed(1)} %`}
labelFormatter={(t) => `Zeitschritt ${t}`}
contentStyle={{
background: "var(--flaeche)",
border: "1px solid var(--rand)",
borderRadius: 6,
color: "var(--text)",
}}
/>
<Legend iconType="plainline" />
{serien.map((s) => (
<Line
key={s.schluessel}
dataKey={s.schluessel}
name={s.name}
stroke={s.farbe}
strokeWidth={2}
strokeDasharray={s.gestrichelt ? "6 4" : undefined}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+272
View File
@@ -0,0 +1,272 @@
/**
* Waldbrand als zellulärer Automat – Portierung von python/ue1.py.
*
* Gleiche Regeln wie in Python, aber ein anderer Zufallsgenerator (mulberry32):
* Läufe mit demselben Seed sind reproduzierbar, stimmen aber nicht mit den Python-Läufen überein.
*/
export const WALD = 0;
export const BRENNEND = 1;
export const ABGEBRANNT = 2;
export const GELOESCHT = 3;
export const SCHNEISE = 4;
export const ZUSTAENDE = [
{ name: "Wald", farbe: "#008300" },
{ name: "brennend", farbe: "#e34948" },
{ name: "abgebrannt", farbe: "#3a3a38" },
{ name: "gelöscht", farbe: "#86b6ef" },
{ name: "Brandschneise", farbe: "#d9d4c7" },
] as const;
// Moore-Nachbarschaft: [Zeilenversatz, Spaltenversatz, Distanzgewicht]
export const NACHBARN: ReadonlyArray<readonly [number, number, number]> = [
[-1, -1, 0.7], [-1, 0, 1.0], [-1, 1, 0.7],
[0, -1, 1.0], [0, 1, 1.0],
[1, -1, 0.7], [1, 0, 1.0], [1, 1, 0.7],
];
export type Zelle = readonly [zeile: number, spalte: number];
export interface Params {
breite: number;
hoehe: number;
/** Anteil natürlicher Brandschneisen (z.B. Wasser, Fels), zufällig verteilt */
schneiseAnteil: number;
/** Künstliche Brandschneisen als volle Zeilen bzw. Spalten */
schneisenZeilen: readonly number[];
schneisenSpalten: readonly number[];
/** Brandfaktor ("Trockenheit") je Waldzelle, gleichverteilt */
brandfaktorMin: number;
brandfaktorMax: number;
/** Restbrenndauer je Waldzelle, ganzzahlig gleichverteilt (inklusive Grenzen) */
brenndauerMin: number;
brenndauerMax: number;
/** (x, y): x zeigt nach Osten, y nach Norden; Richtung, in die der Wind weht */
windRichtung: readonly [number, number];
windStaerke: number;
loeschWahrscheinlichkeit: number;
/** Startzelle, null = zufällige Waldzelle */
start: Zelle | null;
maxSchritte: number;
seed: number;
}
export const STANDARD_PARAMS: Params = {
breite: 100,
hoehe: 100,
schneiseAnteil: 0.05,
schneisenZeilen: [],
schneisenSpalten: [],
brandfaktorMin: 0.1,
brandfaktorMax: 1.0,
brenndauerMin: 2,
brenndauerMax: 5,
windRichtung: [1, 0],
windStaerke: 0,
loeschWahrscheinlichkeit: 0.05,
start: null,
maxSchritte: 1000,
seed: 0,
};
export interface Gitter {
breite: number;
hoehe: number;
zustand: Uint8Array;
brandfaktor: Float32Array;
restbrenndauer: Uint16Array;
}
export type Zufall = () => number;
/** mulberry32: kleiner, schneller Zufallsgenerator mit Seed, liefert Zahlen in [0, 1) */
export function zufallsgenerator(seed: number): Zufall {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 1 + Windstärke * cos(Winkel zwischen Windvektor und Vektor zur Nachbarzelle) */
export function windfaktor(dr: number, dc: number, [wx, wy]: readonly [number, number], staerke: number): number {
const normWind = Math.hypot(wx, wy);
if (staerke === 0 || normWind === 0) return 1;
// Zeilenindex wächst nach unten, y zeigt nach oben
const nx = dc;
const ny = -dr;
return 1 + (staerke * (wx * nx + wy * ny)) / (normWind * Math.hypot(nx, ny));
}
/** Erzeugt Zustand, Brandfaktor und Restbrenndauer und entzündet die Startzelle. */
export function initGitter(params: Params, zufall: Zufall): Gitter {
const { breite, hoehe } = params;
const n = breite * hoehe;
const zustand = new Uint8Array(n);
for (let i = 0; i < n; i++) zustand[i] = zufall() < params.schneiseAnteil ? SCHNEISE : WALD;
if (params.start) {
// Am vorgegebenen Brandherd wächst immer Wald, auch wenn dort zufällig eine natürliche Schneise läge
zustand[params.start[0] * breite + params.start[1]] = WALD;
}
for (const zeile of params.schneisenZeilen) zustand.fill(SCHNEISE, zeile * breite, (zeile + 1) * breite);
for (const spalte of params.schneisenSpalten) {
for (let r = 0; r < hoehe; r++) zustand[r * breite + spalte] = SCHNEISE;
}
// Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise
zustand.fill(SCHNEISE, 0, breite);
zustand.fill(SCHNEISE, (hoehe - 1) * breite, n);
for (let r = 0; r < hoehe; r++) {
zustand[r * breite] = SCHNEISE;
zustand[r * breite + breite - 1] = SCHNEISE;
}
let start: number;
if (params.start === null) {
// Zufällige Waldzelle; hängt nur vom Seed und den Zelltypen ab, daher bei gleichem Seed
// in allen Varianten dieselbe, solange diese die Zelltypen nicht verändern
const waldzellen: number[] = [];
for (let i = 0; i < n; i++) if (zustand[i] === WALD) waldzellen.push(i);
if (waldzellen.length === 0) throw new Error("Keine Waldzelle für den Brandherd vorhanden");
start = waldzellen[Math.floor(zufall() * waldzellen.length)];
} else {
start = params.start[0] * breite + params.start[1];
if (zustand[start] !== WALD) {
throw new Error(`Startzelle (${params.start.join(", ")}) liegt auf einer künstlichen Brandschneise oder am Rand`);
}
}
const brandfaktor = new Float32Array(n);
const restbrenndauer = new Uint16Array(n);
const spanne = params.brandfaktorMax - params.brandfaktorMin;
const dauern = params.brenndauerMax - params.brenndauerMin + 1;
for (let i = 0; i < n; i++) {
// Für jede Zelle ziehen, damit Varianten mit gleichem Seed denselben Wald erhalten
const bf = params.brandfaktorMin + zufall() * spanne;
const dauer = params.brenndauerMin + Math.floor(zufall() * dauern);
if (zustand[i] !== SCHNEISE) {
brandfaktor[i] = bf;
restbrenndauer[i] = dauer;
}
}
zustand[start] = BRENNEND;
return { breite, hoehe, zustand, brandfaktor, restbrenndauer };
}
/** Ein synchroner Zeitschritt: liest nur das alte Gitter und gibt ein neues zurück. */
export function schritt(gitter: Gitter, params: Params, zufall: Zufall): Gitter {
const { breite, hoehe, zustand, brandfaktor } = gitter;
const neu: Gitter = {
breite,
hoehe,
zustand: zustand.slice(),
brandfaktor: brandfaktor.slice(),
restbrenndauer: gitter.restbrenndauer.slice(),
};
const windfaktoren = NACHBARN.map(([dr, dc]) => windfaktor(dr, dc, params.windRichtung, params.windStaerke));
for (let r = 0; r < hoehe; r++) {
for (let c = 0; c < breite; c++) {
const i = r * breite + c;
if (zustand[i] !== BRENNEND) continue;
if (zufall() < params.loeschWahrscheinlichkeit) {
neu.zustand[i] = GELOESCHT;
neu.brandfaktor[i] = 0;
neu.restbrenndauer[i] = 0;
continue;
}
for (let k = 0; k < NACHBARN.length; k++) {
const [dr, dc, gewicht] = NACHBARN[k];
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= hoehe || nc < 0 || nc >= breite) continue;
const j = nr * breite + nc;
if (zustand[j] !== WALD) continue;
if (zufall() < 0.5 * gewicht * brandfaktor[j] * windfaktoren[k]) neu.zustand[j] = BRENNEND;
}
neu.restbrenndauer[i] -= 1;
if (neu.restbrenndauer[i] === 0) {
neu.zustand[i] = ABGEBRANNT;
neu.brandfaktor[i] = 0;
}
}
}
return neu;
}
export type Anzahl = number[];
export function zaehle(zustand: Uint8Array): Anzahl {
const anzahl = new Array<number>(ZUSTAENDE.length).fill(0);
for (const z of zustand) anzahl[z]++;
return anzahl;
}
/** Zellen, die irgendwann gebrannt haben */
export function betroffen(anzahl: Anzahl): number {
return anzahl[BRENNEND] + anzahl[ABGEBRANNT] + anzahl[GELOESCHT];
}
/** Jede Zelle, die keine Brandschneise ist, war zu Beginn Wald */
export function waldflaeche(anzahl: Anzahl): number {
return anzahl.reduce((a, b) => a + b, 0) - anzahl[SCHNEISE];
}
export interface Ergebnis {
breite: number;
hoehe: number;
endzustand: Uint8Array;
/** Anzahl Zellen je Zustand und Zeitschritt, anzahl[0] = Anfangszustand */
anzahl: Anzahl[];
dauer: number;
betroffenAnteil: number;
}
/** Simuliert, bis keine Zelle mehr brennt oder maxSchritte erreicht ist. */
export function simuliere(params: Params): Ergebnis {
const zufall = zufallsgenerator(params.seed);
let gitter = initGitter(params, zufall);
const anzahl = [zaehle(gitter.zustand)];
while (anzahl[anzahl.length - 1][BRENNEND] > 0 && anzahl.length <= params.maxSchritte) {
gitter = schritt(gitter, params, zufall);
anzahl.push(zaehle(gitter.zustand));
}
const letzte = anzahl[anzahl.length - 1];
return {
breite: gitter.breite,
hoehe: gitter.hoehe,
endzustand: gitter.zustand,
anzahl,
dauer: anzahl.length - 1,
betroffenAnteil: betroffen(letzte) / waldflaeche(letzte),
};
}
function imInneren(gitter: Gitter, [r, c]: Zelle): boolean {
return r > 0 && r < gitter.hoehe - 1 && c > 0 && c < gitter.breite - 1;
}
/** Macht eine Waldzelle zur Brandschneise (interaktives Zeichnen). Verändert das Gitter direkt. */
export function setzeSchneise(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = SCHNEISE;
gitter.brandfaktor[i] = 0;
gitter.restbrenndauer[i] = 0;
return true;
}
/** Entzündet eine Waldzelle (interaktiv). Verändert das Gitter direkt. */
export function entzuende(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = BRENNEND;
return true;
}
+403
View File
@@ -0,0 +1,403 @@
:root {
color-scheme: light;
--hintergrund: #f4f3f0;
--flaeche: #fcfcfb;
--text: #0b0b0b;
--text-2: #52514e;
--rand: #dcdad4;
--raster: #e6e4df;
--akzent: #2a78d6;
--akzent-text: #ffffff;
--fehler: #c62828;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--hintergrund: #121211;
--flaeche: #1a1a19;
--text: #f0efec;
--text-2: #c3c2b7;
--rand: #34332f;
--raster: #2a2a28;
--akzent: #3987e5;
--fehler: #ef7b7b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--hintergrund);
color: var(--text);
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
[hidden] {
display: none !important;
}
h1,
h2,
h3 {
margin: 0 0 12px;
line-height: 1.25;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.1rem;
}
h3 {
font-size: 0.95rem;
margin-top: 20px;
color: var(--text-2);
}
p {
margin: 0 0 12px;
}
.kopf {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.kopf p {
margin: 0;
color: var(--text-2);
}
.reiter,
.werkzeug,
.experiment-wahl {
display: inline-flex;
border: 1px solid var(--rand);
border-radius: 8px;
overflow: hidden;
background: var(--flaeche);
}
.experiment-wahl {
margin-bottom: 12px;
}
.reiter__tab,
.werkzeug__option {
border: 0;
background: transparent;
color: var(--text);
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.reiter__tab + .reiter__tab,
.werkzeug__option + .werkzeug__option {
border-left: 1px solid var(--rand);
}
.reiter__tab.aktiv,
.werkzeug__option.aktiv {
background: var(--akzent);
color: var(--akzent-text);
}
.karte {
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: 12px;
padding: 16px;
min-width: 0;
}
.simulation {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"gitter parameter"
"verlauf parameter";
gap: 16px;
align-items: start;
}
.simulation__gitter {
grid-area: gitter;
}
.simulation__parameter {
grid-area: parameter;
}
.simulation__verlauf {
grid-area: verlauf;
}
@media (max-width: 860px) {
.simulation {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "gitter" "parameter" "verlauf";
}
}
.experimente {
display: grid;
gap: 16px;
}
.gitter {
display: block;
width: 100%;
max-width: 640px;
margin: 0 auto;
image-rendering: pixelated;
border-radius: 4px;
}
.gitter--interaktiv {
cursor: crosshair;
touch-action: none;
}
.legende {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 16px;
list-style: none;
padding: 0;
margin: 12px 0;
font-size: 0.875rem;
color: var(--text-2);
}
.legende li {
display: flex;
align-items: center;
gap: 6px;
}
.legende__farbe {
width: 12px;
height: 12px;
border-radius: 3px;
outline: 1px solid var(--rand);
}
.steuerung {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px;
margin-bottom: 12px;
}
.steuerung .werkzeug {
margin-left: auto;
}
.knopf {
border: 1px solid var(--rand);
background: var(--flaeche);
color: var(--text);
border-radius: 8px;
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.knopf:disabled {
opacity: 0.5;
cursor: default;
}
.knopf--primaer {
background: var(--akzent);
border-color: var(--akzent);
color: var(--akzent-text);
min-width: 88px;
}
.kennzahlen {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.kennzahlen div {
border: 1px solid var(--rand);
border-radius: 8px;
padding: 8px 12px;
}
.kennzahlen dt {
font-size: 0.8rem;
color: var(--text-2);
}
.kennzahlen dd {
margin: 0;
font-size: 1.15rem;
font-variant-numeric: tabular-nums;
}
.hinweis {
font-size: 0.875rem;
color: var(--text-2);
}
.feld {
display: grid;
gap: 4px;
margin-bottom: 12px;
font-size: 0.9rem;
}
.feld--inline {
margin-bottom: 0;
}
.feld__zeile {
display: flex;
gap: 8px;
}
.feld__zeile input {
flex: 1;
min-width: 0;
}
input[type="number"],
select {
font: inherit;
color: var(--text);
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: 8px;
padding: 7px 10px;
}
.feld--inline input {
width: 110px;
}
.regler {
display: grid;
gap: 2px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.regler__kopf {
display: flex;
justify-content: space-between;
}
.regler output {
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.regler input {
width: 100%;
accent-color: var(--akzent);
}
progress {
width: 100%;
accent-color: var(--akzent);
}
.fehler {
color: var(--fehler);
}
.tabelle-rahmen {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th,
td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--rand);
white-space: nowrap;
}
th {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-2);
}
.beispiele {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.beispiele figure {
margin: 0;
}
.beispiele .gitter {
max-width: 400px;
}
.beispiele figcaption {
margin-top: 8px;
font-size: 0.875rem;
text-align: center;
color: var(--text-2);
}
.beispiele strong {
color: var(--text);
}
/* Recharts zeichnet SVG; Farben über CSS, damit sie dem Farbschema folgen */
.diagramm .recharts-cartesian-grid line {
stroke: var(--raster);
}
.diagramm .recharts-cartesian-axis-line {
stroke: var(--rand);
}
.diagramm .recharts-text {
fill: var(--text-2);
font-size: 12px;
}
.diagramm .recharts-legend-item-text {
color: var(--text) !important;
}
.diagramm .recharts-tooltip-cursor {
stroke: var(--text-2);
}
+25
View File
@@ -0,0 +1,25 @@
// Führt Experimente im Hintergrund aus, damit die Oberfläche bedienbar bleibt
import { EXPERIMENTE, vergleiche, type ExperimentName, type Statistik } from "./experimente";
export interface Auftrag {
experiment: ExperimentName;
laeufe: number;
seed: number;
}
export type Nachricht =
| { typ: "fortschritt"; fertig: number; gesamt: number }
| { typ: "ergebnis"; statistik: Statistik[] };
// Die Typen der DOM-Bibliothek beschreiben ein Fenster, nicht einen Worker
const kanal = self as unknown as {
onmessage: ((e: MessageEvent<Auftrag>) => void) | null;
postMessage(nachricht: Nachricht): void;
};
kanal.onmessage = ({ data: { experiment, laeufe, seed } }) => {
const statistik = vergleiche(EXPERIMENTE[experiment].varianten, laeufe, seed, (fertig, gesamt) =>
kanal.postMessage({ typ: "fortschritt", fertig, gesamt }),
);
kanal.postMessage({ typ: "ergebnis", statistik });
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
// Relative Pfade, damit die App auch unter einem Unterpfad wie /ue1 läuft
base: "./",
plugins: [react()],
});