Compare commits
2
Commits
bf116ea16f
...
df0950ef11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0950ef11 | ||
|
|
fabbc6d147 |
No files matched your search
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="~/development/modellierung-und-simulation-ue1/.venv" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?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$/python" />
|
||||||
|
<orderEntry type="jdk" jdkName="~/development/modellierung-und-simulation-ue1/.venv" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PackageRequirementsSettings" />
|
||||||
|
<component name="PyDocumentationSettings" />
|
||||||
|
<component name="ReSTService" />
|
||||||
|
<component name="TestRunnerService" />
|
||||||
|
</module>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/modellierung-und-simulation-ue1.iml" filepath="$PROJECT_DIR$/.idea/modellierung-und-simulation-ue1.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Modellierung und Simulation UE1
|
||||||
|
This project is an exercise for the university course in modelling and simulation.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
The requirements by the professor are saved to docs/requirements.md
|
||||||
|
My interpretation of them is available at docs/docs.md
|
||||||
|
An UML diagram is available at docs/uml.svg
|
||||||
|
|
||||||
|
## Web
|
||||||
|
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,2 +1,29 @@
|
|||||||
# modellierung-und-simulation-ue1
|
# Modellierung und Simulation Übung 1
|
||||||
|
Dieses Reposiotry enthält zwei Projekte:
|
||||||
|
1) Im Ordner "python" die Python-Implementierung
|
||||||
|
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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Zellulärer Automat
|
||||||
|
|
||||||
|
Eigenschaften einer Zelle:
|
||||||
|
Brandfaktor (quasi die “Trockenheit”)
|
||||||
|
Restbrenndauer
|
||||||
|
|
||||||
|
Zustände:
|
||||||
|
Wald: Brandfaktor zwischen 0,1 und 1 (“Trockenheit”), Restbrenndauer konstant
|
||||||
|
Brennend: Brandfaktor irrelevant, Restbrenndauer sinkend
|
||||||
|
Abgebrannt, Brandfaktor 0, Restbrenndauer 0
|
||||||
|
Gelöscht, Brandfaktor 0, Restbrenndauer 0
|
||||||
|
Brandschneise (künstlich oder natürlich wie Wasser), Brandfaktor 0, Restbrenndauer 0
|
||||||
|
|
||||||
|
Die Simulation behandelt die Zustände Brandschneise, Abgebrannt und Gelöscht gleich, aber für die Visualisierung sind diese Zustände interessant
|
||||||
|
|
||||||
|
Nachbarschaft: 8 Zellen
|
||||||
|
|
||||||
|
Faktoren für Verbreitung:
|
||||||
|
Nachbarschaft: direkt mit Gewichtung 1, diagonal mit Gewichtung 0.7 (Moore)
|
||||||
|
Windrichtung: Vektor (x, y)
|
||||||
|
Windstärke: 0-1 (0=kein Wind, 1=Wind so stark, dass er Wahrscheinlichkeit in Windrichtung verdoppelt)
|
||||||
|
|
||||||
|
Windfaktor für Verbreitung:
|
||||||
|
1 + Windstärke * cos(Winkel(Vektorwind, Vektorzellen))
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Ausgangspunkt ist die Idee des zellulären Automaten, wie wir sie am Beispiel des Game of Life kennengelernt haben. Ein Raum wird in Zellen aufgeteilt. Jede Zelle besitzt einen Zustand und verändert diesen in diskreten Zeitschritten aufgrund festgelegter Regeln und der Zustände ihrer Nachbarzellen.
|
||||||
|
|
||||||
|
Entwickelt auf dieser Grundlage ein eigenes Modell für die Ausbreitung eines Waldbrandes.
|
||||||
|
|
||||||
|
Legt zunächst euer Modell fest und beschreibt:
|
||||||
|
|
||||||
|
Welche Zustände kann eine Zelle haben? Zum Beispiel Wald, brennend, abgebrannt, Wasser oder Brandschneise.
|
||||||
|
Welche Nachbarschaft wird verwendet? Vier oder acht angrenzende Zellen?
|
||||||
|
Unter welchen Bedingungen entzündet sich eine Zelle?
|
||||||
|
Was passiert mit einer brennenden Zelle im nächsten Zeitschritt?
|
||||||
|
Welche zusätzlichen Eigenschaften oder Parameter können berücksichtigt werden? zB Trockenheit, Feuchtigkeit, Wind oder Menge Löschwasser?
|
||||||
|
Wie wird der Rand des Modells behandelt?
|
||||||
|
Formuliert die Regeln so genau, dass eine andere Person eure Simulation allein anhand dieser Beschreibung implementieren könnte.
|
||||||
|
|
||||||
|
Setzt das Modell anschließend in Python um. Ihr dürft dafür KI verwenden.
|
||||||
|
|
||||||
|
Führt danach mindestens zwei unterschiedliche Simulationen durch, indem ihr gezielt einen Parameter oder eine Maßnahme verändert, zum Beispiel Trockenheit, Wind, Brandschneisen oder Löschwasser.
|
||||||
|
|
||||||
|
Vergleicht die Ergebnisse und beantwortet abschließend kurz:
|
||||||
|
|
||||||
|
Was lässt sich mit eurem Modell untersuchen? Welche Annahmen und Vereinfachungen habt ihr getroffen? Welche Aspekte eines echten Waldbrandes bildet euer Modell nicht ab? Was musstet ihr bei der Umsetzung mit KI eventuell korrigieren oder präzisieren?
|
||||||
|
|
||||||
|
Abgabe
|
||||||
|
|
||||||
|
Gebt das in einem PDF Dokument oder Colab Notebook mit dieser Struktur ab:
|
||||||
|
|
||||||
|
Modellbeschreibung
|
||||||
|
Python-Code oder einen Link zur lauffähigen Simulation
|
||||||
|
die Ergebnisse eurer beiden Versuche und
|
||||||
|
kurze Reflexion
|
||||||
|
Modus: Einzelarbeit oder Gruppenarbeit (max 2)
|
||||||
|
|
||||||
|
Frist: 9.11. (nächste Einheit)
|
||||||
@@ -0,0 +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="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
|
||||||
|
After Width: | Height: | Size: 425 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
[project]
|
||||||
|
name = "modellierung-und-simulation-ue1"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"matplotlib>=3.5",
|
||||||
|
"numpy>=1.22",
|
||||||
|
]
|
||||||
+414
@@ -0,0 +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