feat: add python and web implementation
This commit is contained in:
1 parent
fabbc6d147
commit
df0950ef11
30 files changed
+5457
-7
No files matched your search
+1
-3
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
web:
|
||||
build: ./web
|
||||
image: waldbrand-simulation
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
@@ -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
@@ -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="<mxfile host="app.diagrams.net" scale="1" border="0"> <diagram name="Page-1" id="ybR0R8uh0gsWY412Z8hE"> <mxGraphModel dx="1291" dy="2434" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0"> <root> <mxCell id="0" /> <mxCell id="1" parent="0" /> <mxCell id="vpmAtYMeYcpatCYC-Gde-1" parent="1" style="ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;" value="" vertex="1"> <mxGeometry height="30" width="30" x="372.5" y="-90" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-2" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-1" style="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;" target="vpmAtYMeYcpatCYC-Gde-59" value=""> <mxGeometry relative="1" as="geometry"> <mxPoint x="255" y="280" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-7" parent="1" style="shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;" value="Für jede Zelle" vertex="1"> <mxGeometry height="700" width="1130" x="180" y="150" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-25" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-8" style="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;" target="vpmAtYMeYcpatCYC-Gde-24"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-54" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-8" style="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;" target="vpmAtYMeYcpatCYC-Gde-64"> <mxGeometry relative="1" as="geometry"> <mxPoint x="255" y="770" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-8" parent="1" style="rhombus;whiteSpace=wrap;html=1;shapeInside=1;" value="Zellenzustand&lt;div&gt;ist brennend?&lt;/div&gt;" vertex="1"> <mxGeometry height="120" width="120" x="195" y="280" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-20" parent="1" style="shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=100;height=40;" value="Für jeden Moore-Nachbarn" vertex="1"> <mxGeometry height="540" width="440" x="827" y="260" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-40" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-22" style="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;" target="vpmAtYMeYcpatCYC-Gde-64"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="710" y="680" 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="<mxfile host="app.diagrams.net" scale="1" border="0"> <diagram name="Page-1" id="ybR0R8uh0gsWY412Z8hE"> <mxGraphModel dx="1291" dy="2434" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0"> <root> <mxCell id="0" /> <mxCell id="1" parent="0" /> <mxCell id="vpmAtYMeYcpatCYC-Gde-1" parent="1" style="ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;" value="" vertex="1"> <mxGeometry height="30" width="30" x="372.5" y="-90" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-2" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-1" style="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;" target="vpmAtYMeYcpatCYC-Gde-59" value=""> <mxGeometry relative="1" as="geometry"> <mxPoint x="255" y="280" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-7" parent="1" style="shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=70;height=70;" value="Für jede Zelle im alten Zustand" vertex="1"> <mxGeometry height="700" width="1130" x="190" y="330" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-25" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-8" style="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;" target="vpmAtYMeYcpatCYC-Gde-24"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-54" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-8" style="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;" target="EYQY27FIURCIZYL1lPWE-6"> <mxGeometry relative="1" as="geometry"> <mxPoint x="265" y="950" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-8" parent="1" style="rhombus;whiteSpace=wrap;html=1;shapeInside=1;" value="Zellenzustand&lt;div&gt;ist brennend?&lt;/div&gt;" vertex="1"> <mxGeometry height="120" width="120" x="205" y="460" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-20" parent="1" style="shape=umlFrame;whiteSpace=wrap;html=1;pointerEvents=0;width=103;height=50;" value="Für jeden Moore-Nachbarn im alten Zustand" vertex="1"> <mxGeometry height="540" width="440" x="837" y="440" as="geometry" /> </mxCell> <mxCell id="vpmAtYMeYcpatCYC-Gde-40" edge="1" parent="1" source="vpmAtYMeYcpatCYC-Gde-22" style="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;" target="EYQY27FIURCIZYL1lPWE-6"> <mxGeometry relative="1" as="geometry"> <Array Line truncated
|
||||
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 425 KiB |
@@ -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
@@ -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()
|
||||
Generated
+1701
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Generated
+1694
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
Reference in new issue
Block a user