feat: add python and web implementation
This commit is contained in:
1 parent
fabbc6d147
commit
df0950ef11
30 files changed
+5458
-8
No files matched your search
@@ -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