feat: add python and web implementation

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

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
Dockerfile
.dockerignore
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+10
View File
@@ -0,0 +1,10 @@
FROM node:26-trixie-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:mainline-trixie
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Waldbrand-Simulation als zellulärer Automat" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>"
/>
<title>Waldbrand-Simulation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}
# Dateinamen in /assets enthalten einen Hash und ändern sich mit jedem Build
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
+1694
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "waldbrand-simulation",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.3.1",
"react-dom": "^19.3.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"typescript": "^7.0.2",
"vite": "^8.3.5"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from "react";
import Experimente from "./komponenten/Experimente";
import Simulation from "./komponenten/Simulation";
type Ansicht = "simulation" | "experimente";
export default function App() {
const [ansicht, setAnsicht] = useState<Ansicht>("simulation");
return (
<>
<header className="kopf">
<div>
<h1>Waldbrand-Simulation</h1>
<p>Zellulärer Automat – Modellierung und Simulation, Übung 1</p>
</div>
<nav className="reiter" role="tablist">
{(
[
["simulation", "Simulation"],
["experimente", "Experimente"],
] as const
).map(([wert, name]) => (
<button
key={wert}
role="tab"
aria-selected={ansicht === wert}
className={ansicht === wert ? "reiter__tab aktiv" : "reiter__tab"}
onClick={() => setAnsicht(wert)}
>
{name}
</button>
))}
</nav>
</header>
{/* Beide Ansichten bleiben eingehängt, damit ein laufender Brand beim Wechsel erhalten bleibt */}
<main hidden={ansicht !== "simulation"}>
<Simulation />
</main>
<main hidden={ansicht !== "experimente"}>
<Experimente />
</main>
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { simuliere, STANDARD_PARAMS, type Ergebnis, type Params } from "./modell";
export interface Experiment {
titel: string;
beschreibung: string;
varianten: Record<string, Params>;
}
const MITTE = [STANDARD_PARAMS.hoehe / 2, STANDARD_PARAMS.breite / 2] as const;
export const EXPERIMENTE = {
wind: {
titel: "Wind",
beschreibung: "Windstärke 0 vs. 0.8, Wind nach Osten.",
varianten: {
"ohne Wind": STANDARD_PARAMS,
"Wind 0.8 nach Osten": { ...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8 },
},
},
schneise: {
titel: "Brandschneise",
beschreibung:
"Wind 0.8 nach Osten, ohne vs. mit durchgehender Schneise östlich des Brandherds. " +
"Der Brandherd liegt fest in der Mitte, damit der Vergleich nicht vom zufälligen Startpunkt abhängt.",
varianten: {
"ohne Schneise": { ...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8, start: MITTE },
"Schneise in Spalte 65": {
...STANDARD_PARAMS, windRichtung: [1, 0], windStaerke: 0.8, start: MITTE, schneisenSpalten: [65],
},
},
},
trockenheit: {
titel: "Trockenheit",
beschreibung: "Brandfaktor feucht (0.1–0.5) vs. normal (0.1–1.0) vs. trocken (0.5–1.0).",
varianten: {
"feucht (0.1–0.5)": { ...STANDARD_PARAMS, brandfaktorMin: 0.1, brandfaktorMax: 0.5 },
"normal (0.1–1.0)": STANDARD_PARAMS,
"trocken (0.5–1.0)": { ...STANDARD_PARAMS, brandfaktorMin: 0.5, brandfaktorMax: 1.0 },
},
},
} satisfies Record<string, Experiment>;
export type ExperimentName = keyof typeof EXPERIMENTE;
export interface Statistik {
name: string;
betroffenMittel: number;
betroffenStd: number;
dauerMittel: number;
dauerStd: number;
grossbrandAnteil: number;
/** Lauf mit dem Median der betroffenen Fläche, als Beispiel für die Grafik */
beispiel: Ergebnis;
}
function mittelUndStd(werte: number[]): [number, number] {
const mittel = werte.reduce((a, b) => a + b, 0) / werte.length;
const varianz = werte.reduce((a, b) => a + (b - mittel) ** 2, 0) / werte.length;
return [mittel, Math.sqrt(varianz)];
}
/**
* Simuliert jede Variante mit denselben Seeds. Gleiche Seeds bedeuten: alle Varianten starten mit
* demselben Wald und Brandherd, soweit die Variante die Initialisierung nicht selbst ändert.
*/
export function vergleiche(
varianten: Record<string, Params>,
laeufe: number,
seed: number,
fortschritt?: (fertig: number, gesamt: number) => void,
): Statistik[] {
const gesamt = Object.keys(varianten).length * laeufe;
let fertig = 0;
return Object.entries(varianten).map(([name, params]) => {
const ergebnisse: Ergebnis[] = [];
for (let i = 0; i < laeufe; i++) {
ergebnisse.push(simuliere({ ...params, seed: seed + i }));
fortschritt?.(++fertig, gesamt);
}
const anteile = ergebnisse.map((e) => e.betroffenAnteil);
const [betroffenMittel, betroffenStd] = mittelUndStd(anteile);
const [dauerMittel, dauerStd] = mittelUndStd(ergebnisse.map((e) => e.dauer));
const sortiert = [...ergebnisse].sort((a, b) => a.betroffenAnteil - b.betroffenAnteil);
return {
name,
betroffenMittel,
betroffenStd,
dauerMittel,
dauerStd,
grossbrandAnteil: anteile.filter((a) => a > 0.5).length / laeufe,
beispiel: sortiert[Math.floor(laeufe / 2)],
};
});
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { betroffen, waldflaeche } from "../modell";
import { EXPERIMENTE, type ExperimentName, type Statistik } from "../experimente";
import type { Auftrag, Nachricht } from "../worker";
import Gitter from "./Gitter";
import Legende from "./Legende";
import Verlaufsdiagramm, { type Datenpunkt, type Serie } from "./Verlaufsdiagramm";
// Feste Reihenfolge: Variante i bekommt immer Farbe i
const VARIANTEN_FARBEN = ["#2a78d6", "#eb6834", "#1baf7a"];
const prozent = (wert: number) => `${(wert * 100).toFixed(1)} %`;
/** Betroffene Fläche je Variante, zusammengeführt nach Zeitschritt */
function vergleichsdaten(statistik: Statistik[]): Datenpunkt[] {
const laengste = Math.max(...statistik.map((s) => s.beispiel.dauer));
return Array.from({ length: laengste + 1 }, (_, t) => {
const punkt: Datenpunkt = { t };
statistik.forEach((s, i) => {
const anzahl = s.beispiel.anzahl[t];
if (anzahl) punkt[`v${i}`] = (betroffen(anzahl) / waldflaeche(anzahl)) * 100;
});
return punkt;
});
}
interface Auswertung {
experiment: ExperimentName;
laeufe: number;
statistik: Statistik[];
}
export default function Experimente() {
const [experiment, setExperiment] = useState<ExperimentName>("wind");
const [laeufe, setLaeufe] = useState(20);
const [seed, setSeed] = useState(0);
const [fortschritt, setFortschritt] = useState<number | null>(null);
const [auswertung, setAuswertung] = useState<Auswertung | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => () => workerRef.current?.terminate(), []);
function starten() {
workerRef.current?.terminate();
const worker = new Worker(new URL("../worker.ts", import.meta.url), { type: "module" });
workerRef.current = worker;
setFortschritt(0);
setFehler(null);
worker.onmessage = ({ data }: MessageEvent<Nachricht>) => {
if (data.typ === "fortschritt") {
setFortschritt(data.fertig / data.gesamt);
} else {
setAuswertung({ experiment, laeufe, statistik: data.statistik });
setFortschritt(null);
worker.terminate();
}
};
worker.onerror = (e) => {
setFehler(e.message);
setFortschritt(null);
};
worker.postMessage({ experiment, laeufe, seed } satisfies Auftrag);
}
const serien: Serie[] =
auswertung?.statistik.map((s, i) => ({ schluessel: `v${i}`, name: s.name, farbe: VARIANTEN_FARBEN[i] })) ?? [];
return (
<div className="experimente">
<section className="karte">
<h2>Experiment</h2>
<div className="experiment-wahl" role="radiogroup" aria-label="Experiment">
{(Object.keys(EXPERIMENTE) as ExperimentName[]).map((name) => (
<button
key={name}
role="radio"
aria-checked={experiment === name}
className={experiment === name ? "werkzeug__option aktiv" : "werkzeug__option"}
onClick={() => setExperiment(name)}
>
{EXPERIMENTE[name].titel}
</button>
))}
</div>
<p>{EXPERIMENTE[experiment].beschreibung}</p>
<p className="hinweis">
Jede Variante läuft mit denselben Seeds, startet also mit demselben Wald und Brandherd. Alle übrigen
Parameter haben ihre Standardwerte (Löschwahrscheinlichkeit 5 %, Brenndauer 2–5, 5 % natürliche Schneisen).
</p>
<div className="steuerung">
<label className="feld feld--inline">
Läufe je Variante
<input
type="number"
min={1}
max={200}
value={laeufe}
onChange={(e) => setLaeufe(Math.min(200, Math.max(1, Math.floor(Number(e.target.value)))))}
/>
</label>
<label className="feld feld--inline">
Seed
<input
type="number"
min={0}
value={seed}
onChange={(e) => setSeed(Math.max(0, Math.floor(Number(e.target.value))))}
/>
</label>
<button className="knopf knopf--primaer" onClick={starten} disabled={fortschritt !== null}>
{fortschritt !== null ? "Läuft …" : "Starten"}
</button>
</div>
{fortschritt !== null && <progress value={fortschritt} max={1} />}
{fehler && <p className="fehler">Fehler: {fehler}</p>}
</section>
{auswertung && (
<>
<section className="karte">
<h2>
Ergebnis: {EXPERIMENTE[auswertung.experiment].titel} ({auswertung.laeufe} Läufe je Variante)
</h2>
<div className="tabelle-rahmen">
<table>
<thead>
<tr>
<th>Variante</th>
<th>betroffen (Mittel ± Std)</th>
<th>Dauer in Schritten (Mittel ± Std)</th>
<th>Läufe &gt; 50 % betroffen</th>
</tr>
</thead>
<tbody>
{auswertung.statistik.map((s) => (
<tr key={s.name}>
<td>{s.name}</td>
<td>
{prozent(s.betroffenMittel)} ± {prozent(s.betroffenStd)}
</td>
<td>
{s.dauerMittel.toFixed(1)} ± {s.dauerStd.toFixed(1)}
</td>
<td>{Math.round(s.grossbrandAnteil * 100)} %</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="karte">
<h2>Typischer Lauf je Variante</h2>
<p className="hinweis">Endzustand des Laufs mit dem Median der betroffenen Fläche.</p>
<div className="beispiele">
{auswertung.statistik.map((s) => (
<figure key={s.name}>
<Gitter
breite={s.beispiel.breite}
hoehe={s.beispiel.hoehe}
zustand={s.beispiel.endzustand}
beschriftung={`Endzustand ${s.name}`}
/>
<figcaption>
<strong>{s.name}</strong>
<br />
betroffen {prozent(s.beispiel.betroffenAnteil)}, {s.beispiel.dauer} Schritte
</figcaption>
</figure>
))}
</div>
<Legende />
<h3>Betroffene Fläche über die Zeit</h3>
<Verlaufsdiagramm daten={vergleichsdaten(auswertung.statistik)} serien={serien} />
</section>
</>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, type PointerEvent } from "react";
import { ZUSTAENDE, type Zelle } from "../modell";
// Zustandsfarben als RGB, einmal vorberechnet
const FARBEN_RGB = ZUSTAENDE.map(({ farbe }) => [1, 3, 5].map((i) => parseInt(farbe.slice(i, i + 2), 16)));
interface Props {
breite: number;
hoehe: number;
zustand: Uint8Array;
/** Erhöhen, um neu zu zeichnen, wenn das Gitter direkt verändert wurde */
version?: number;
/** Wird beim Klicken und Ziehen mit der Zelle unter dem Zeiger aufgerufen */
onZelle?: (zelle: Zelle) => void;
beschriftung: string;
}
export default function Gitter({ breite, hoehe, zustand, version, onZelle, beschriftung }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const maltRef = useRef(false);
useEffect(() => {
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const bild = ctx.createImageData(breite, hoehe);
for (let i = 0; i < zustand.length; i++) {
const [r, g, b] = FARBEN_RGB[zustand[i]];
bild.data[4 * i] = r;
bild.data[4 * i + 1] = g;
bild.data[4 * i + 2] = b;
bild.data[4 * i + 3] = 255;
}
ctx.putImageData(bild, 0, 0);
}, [breite, hoehe, zustand, version]);
function zelleUnterZeiger(e: PointerEvent<HTMLCanvasElement>): Zelle {
const rechteck = e.currentTarget.getBoundingClientRect();
return [
Math.floor(((e.clientY - rechteck.top) / rechteck.height) * hoehe),
Math.floor(((e.clientX - rechteck.left) / rechteck.width) * breite),
];
}
return (
<canvas
ref={canvasRef}
className={onZelle ? "gitter gitter--interaktiv" : "gitter"}
width={breite}
height={hoehe}
style={{ aspectRatio: `${breite} / ${hoehe}` }}
role="img"
aria-label={beschriftung}
onPointerDown={(e) => {
if (!onZelle) return;
maltRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
onZelle(zelleUnterZeiger(e));
}}
onPointerMove={(e) => {
if (onZelle && maltRef.current) onZelle(zelleUnterZeiger(e));
}}
onPointerUp={() => (maltRef.current = false)}
onPointerCancel={() => (maltRef.current = false)}
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ZUSTAENDE } from "../modell";
export default function Legende() {
return (
<ul className="legende">
{ZUSTAENDE.map(({ name, farbe }) => (
<li key={name}>
<span className="legende__farbe" style={{ background: farbe }} />
{name}
</li>
))}
</ul>
);
}
+28
View File
@@ -0,0 +1,28 @@
interface Props {
name: string;
wert: number;
min: number;
max: number;
schritt: number;
format?: (wert: number) => string;
onChange: (wert: number) => void;
}
export default function Regler({ name, wert, min, max, schritt, format = String, onChange }: Props) {
return (
<label className="regler">
<span className="regler__kopf">
<span>{name}</span>
<output>{format(wert)}</output>
</span>
<input
type="range"
min={min}
max={max}
step={schritt}
value={wert}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
BRENNEND,
betroffen,
entzuende,
initGitter,
schritt,
setzeSchneise,
STANDARD_PARAMS,
waldflaeche,
zaehle,
zufallsgenerator,
type Gitter as GitterDaten,
type Params,
type Zelle,
type Zufall,
} from "../modell";
import Gitter from "./Gitter";
import Legende from "./Legende";
import Regler from "./Regler";
import Verlaufsdiagramm, { type Datenpunkt, type Serie } from "./Verlaufsdiagramm";
const WINDRICHTUNGEN: { name: string; vektor: readonly [number, number] }[] = [
{ name: "→ nach Osten", vektor: [1, 0] },
{ name: "↗ nach Nordosten", vektor: [1, 1] },
{ name: "↑ nach Norden", vektor: [0, 1] },
{ name: "↖ nach Nordwesten", vektor: [-1, 1] },
{ name: "← nach Westen", vektor: [-1, 0] },
{ name: "↙ nach Südwesten", vektor: [-1, -1] },
{ name: "↓ nach Süden", vektor: [0, -1] },
{ name: "↘ nach Südosten", vektor: [1, -1] },
];
const GROESSEN = [50, 100, 150, 200];
const SERIEN: Serie[] = [
{ schluessel: "betroffen", name: "betroffen gesamt", farbe: "#2a78d6" },
{ schluessel: "brennend", name: "brennend", farbe: "#eb6834" },
];
type Werkzeug = "schneise" | "feuer";
interface Lauf {
gitter: GitterDaten;
zufall: Zufall;
t: number;
}
function messpunkt(t: number, gitter: GitterDaten): Datenpunkt {
const anzahl = zaehle(gitter.zustand);
const flaeche = waldflaeche(anzahl);
return {
t,
betroffen: (betroffen(anzahl) / flaeche) * 100,
brennend: (anzahl[BRENNEND] / flaeche) * 100,
};
}
const prozent = (wert: number) => `${Math.round(wert * 100)} %`;
export default function Simulation() {
const [params, setParams] = useState<Params>({ ...STANDARD_PARAMS, seed: 1 });
const [laeuft, setLaeuft] = useState(false);
const [tempo, setTempo] = useState(20);
const [werkzeug, setWerkzeug] = useState<Werkzeug>("schneise");
const [verlauf, setVerlauf] = useState<Datenpunkt[]>([]);
const [version, setVersion] = useState(0);
const laufRef = useRef<Lauf | null>(null);
// Wind und Löschwahrscheinlichkeit wirken sofort, auch während die Simulation läuft
const paramsRef = useRef(params);
paramsRef.current = params;
function aendere<K extends keyof Params>(schluessel: K, wert: Params[K]) {
setParams((p) => ({ ...p, [schluessel]: wert }));
}
// Änderungen an Parametern, die die Initialisierung betreffen, erzeugen einen neuen Wald
const { breite, hoehe, schneiseAnteil, brandfaktorMin, brandfaktorMax, brenndauerMin, brenndauerMax, seed } = params;
const neuStarten = useCallback(() => {
const zufall = zufallsgenerator(paramsRef.current.seed);
const gitter = initGitter(paramsRef.current, zufall);
laufRef.current = { gitter, zufall, t: 0 };
setVerlauf([messpunkt(0, gitter)]);
setVersion((v) => v + 1);
setLaeuft(false);
}, []);
useEffect(neuStarten, [
neuStarten, breite, hoehe, schneiseAnteil, brandfaktorMin, brandfaktorMax, brenndauerMin, brenndauerMax, seed,
]);
const einSchritt = useCallback(() => {
const lauf = laufRef.current;
if (!lauf) return;
lauf.gitter = schritt(lauf.gitter, paramsRef.current, lauf.zufall);
lauf.t += 1;
const punkt = messpunkt(lauf.t, lauf.gitter);
setVerlauf((v) => [...v, punkt]);
setVersion((v) => v + 1);
if (punkt.brennend === 0) setLaeuft(false);
}, []);
useEffect(() => {
if (!laeuft) return;
const id = setInterval(einSchritt, 1000 / tempo);
return () => clearInterval(id);
}, [laeuft, tempo, einSchritt]);
const onZelle = useCallback(
(zelle: Zelle) => {
const lauf = laufRef.current;
if (!lauf) return;
const geaendert = werkzeug === "schneise" ? setzeSchneise(lauf.gitter, zelle) : entzuende(lauf.gitter, zelle);
if (!geaendert) return;
const punkt = messpunkt(lauf.t, lauf.gitter);
setVerlauf((v) => [...v.slice(0, -1), punkt]);
setVersion((v) => v + 1);
},
[werkzeug],
);
const lauf = laufRef.current;
const aktuell = verlauf[verlauf.length - 1];
const brennt = aktuell !== undefined && aktuell.brennend > 0;
const brennendeZellen = lauf ? zaehle(lauf.gitter.zustand)[BRENNEND] : 0;
const status = laeuft ? "läuft" : !brennt ? "beendet" : lauf?.t === 0 ? "bereit" : "pausiert";
const windIndex = WINDRICHTUNGEN.findIndex(
({ vektor }) => vektor[0] === params.windRichtung[0] && vektor[1] === params.windRichtung[1],
);
return (
<div className="simulation">
<section className="karte simulation__gitter">
{lauf && (
<Gitter
breite={lauf.gitter.breite}
hoehe={lauf.gitter.hoehe}
zustand={lauf.gitter.zustand}
version={version}
onZelle={onZelle}
beschriftung={`Waldgitter nach ${lauf.t} Zeitschritten`}
/>
)}
<Legende />
<div className="steuerung">
<button className="knopf knopf--primaer" onClick={() => setLaeuft(!laeuft)} disabled={!brennt}>
{laeuft ? "Pause" : "Start"}
</button>
<button className="knopf" onClick={einSchritt} disabled={laeuft || !brennt}>
Schritt
</button>
<button className="knopf" onClick={neuStarten}>
Neu
</button>
<div className="werkzeug" role="radiogroup" aria-label="Werkzeug">
{(
[
["schneise", "Schneise zeichnen"],
["feuer", "Feuer legen"],
] as const
).map(([wert, name]) => (
<button
key={wert}
role="radio"
aria-checked={werkzeug === wert}
className={werkzeug === wert ? "werkzeug__option aktiv" : "werkzeug__option"}
onClick={() => setWerkzeug(wert)}
>
{name}
</button>
))}
</div>
</div>
<dl className="kennzahlen">
<div>
<dt>Zeitschritt</dt>
<dd>{lauf?.t ?? 0}</dd>
</div>
<div>
<dt>betroffen</dt>
<dd>{aktuell ? aktuell.betroffen.toFixed(1) : 0} %</dd>
</div>
<div>
<dt>brennend</dt>
<dd>{brennendeZellen === 1 ? "1 Zelle" : `${brennendeZellen} Zellen`}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{status}</dd>
</div>
</dl>
<p className="hinweis">
Auf dem Gitter klicken oder ziehen, um Brandschneisen zu zeichnen oder neue Brandherde zu legen.
</p>
</section>
<section className="karte simulation__parameter">
<h2>Parameter</h2>
<div className="feld">
<label htmlFor="groesse">Gittergröße</label>
<select
id="groesse"
value={params.breite}
onChange={(e) => setParams((p) => ({ ...p, breite: Number(e.target.value), hoehe: Number(e.target.value) }))}
>
{GROESSEN.map((g) => (
<option key={g} value={g}>
{g} × {g}
</option>
))}
</select>
</div>
<div className="feld">
<label htmlFor="seed">Seed</label>
<div className="feld__zeile">
<input
id="seed"
type="number"
min={0}
value={params.seed}
onChange={(e) => aendere("seed", Math.max(0, Math.floor(Number(e.target.value))))}
/>
<button className="knopf" onClick={() => aendere("seed", Math.floor(Math.random() * 1_000_000))}>
Zufällig
</button>
</div>
</div>
<h3>Wald</h3>
<Regler
name="Natürliche Schneisen"
wert={params.schneiseAnteil}
min={0}
max={0.4}
schritt={0.01}
format={prozent}
onChange={(w) => aendere("schneiseAnteil", w)}
/>
<Regler
name="Brandfaktor min"
wert={params.brandfaktorMin}
min={0.1}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => setParams((p) => ({ ...p, brandfaktorMin: w, brandfaktorMax: Math.max(w, p.brandfaktorMax) }))}
/>
<Regler
name="Brandfaktor max"
wert={params.brandfaktorMax}
min={0.1}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => setParams((p) => ({ ...p, brandfaktorMax: w, brandfaktorMin: Math.min(w, p.brandfaktorMin) }))}
/>
<Regler
name="Brenndauer min"
wert={params.brenndauerMin}
min={1}
max={10}
schritt={1}
onChange={(w) => setParams((p) => ({ ...p, brenndauerMin: w, brenndauerMax: Math.max(w, p.brenndauerMax) }))}
/>
<Regler
name="Brenndauer max"
wert={params.brenndauerMax}
min={1}
max={10}
schritt={1}
onChange={(w) => setParams((p) => ({ ...p, brenndauerMax: w, brenndauerMin: Math.min(w, p.brenndauerMin) }))}
/>
<h3>Während der Simulation änderbar</h3>
<div className="feld">
<label htmlFor="windrichtung">Windrichtung</label>
<select
id="windrichtung"
value={windIndex}
onChange={(e) => aendere("windRichtung", WINDRICHTUNGEN[Number(e.target.value)].vektor)}
>
{WINDRICHTUNGEN.map(({ name }, i) => (
<option key={name} value={i}>
{name}
</option>
))}
</select>
</div>
<Regler
name="Windstärke"
wert={params.windStaerke}
min={0}
max={1}
schritt={0.05}
format={(w) => w.toFixed(2)}
onChange={(w) => aendere("windStaerke", w)}
/>
<Regler
name="Löschwahrscheinlichkeit"
wert={params.loeschWahrscheinlichkeit}
min={0}
max={0.5}
schritt={0.01}
format={prozent}
onChange={(w) => aendere("loeschWahrscheinlichkeit", w)}
/>
<Regler
name="Tempo"
wert={tempo}
min={1}
max={60}
schritt={1}
format={(w) => `${w} Schritte/s`}
onChange={setTempo}
/>
</section>
<section className="karte simulation__verlauf">
<h2>Verlauf</h2>
<Verlaufsdiagramm daten={verlauf} serien={SERIEN} />
</section>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export interface Serie {
schluessel: string;
name: string;
farbe: string;
gestrichelt?: boolean;
}
export type Datenpunkt = { t: number } & Record<string, number>;
interface Props {
daten: Datenpunkt[];
serien: Serie[];
}
/** Prozent der Waldfläche über die Zeitschritte */
export default function Verlaufsdiagramm({ daten, serien }: Props) {
return (
<div className="diagramm">
<ResponsiveContainer width="100%" height={260}>
<LineChart data={daten} margin={{ top: 8, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" type="number" domain={[0, "dataMax"]} allowDecimals={false} tickLine={false} />
<YAxis domain={[0, 100]} unit=" %" width={52} tickLine={false} axisLine={false} />
<Tooltip
formatter={(wert) => `${Number(wert).toFixed(1)} %`}
labelFormatter={(t) => `Zeitschritt ${t}`}
contentStyle={{
background: "var(--flaeche)",
border: "1px solid var(--rand)",
borderRadius: 6,
color: "var(--text)",
}}
/>
<Legend iconType="plainline" />
{serien.map((s) => (
<Line
key={s.schluessel}
dataKey={s.schluessel}
name={s.name}
stroke={s.farbe}
strokeWidth={2}
strokeDasharray={s.gestrichelt ? "6 4" : undefined}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+272
View File
@@ -0,0 +1,272 @@
/**
* Waldbrand als zellulärer Automat – Portierung von python/ue1.py.
*
* Gleiche Regeln wie in Python, aber ein anderer Zufallsgenerator (mulberry32):
* Läufe mit demselben Seed sind reproduzierbar, stimmen aber nicht mit den Python-Läufen überein.
*/
export const WALD = 0;
export const BRENNEND = 1;
export const ABGEBRANNT = 2;
export const GELOESCHT = 3;
export const SCHNEISE = 4;
export const ZUSTAENDE = [
{ name: "Wald", farbe: "#008300" },
{ name: "brennend", farbe: "#e34948" },
{ name: "abgebrannt", farbe: "#3a3a38" },
{ name: "gelöscht", farbe: "#86b6ef" },
{ name: "Brandschneise", farbe: "#d9d4c7" },
] as const;
// Moore-Nachbarschaft: [Zeilenversatz, Spaltenversatz, Distanzgewicht]
export const NACHBARN: ReadonlyArray<readonly [number, number, number]> = [
[-1, -1, 0.7], [-1, 0, 1.0], [-1, 1, 0.7],
[0, -1, 1.0], [0, 1, 1.0],
[1, -1, 0.7], [1, 0, 1.0], [1, 1, 0.7],
];
export type Zelle = readonly [zeile: number, spalte: number];
export interface Params {
breite: number;
hoehe: number;
/** Anteil natürlicher Brandschneisen (z.B. Wasser, Fels), zufällig verteilt */
schneiseAnteil: number;
/** Künstliche Brandschneisen als volle Zeilen bzw. Spalten */
schneisenZeilen: readonly number[];
schneisenSpalten: readonly number[];
/** Brandfaktor ("Trockenheit") je Waldzelle, gleichverteilt */
brandfaktorMin: number;
brandfaktorMax: number;
/** Restbrenndauer je Waldzelle, ganzzahlig gleichverteilt (inklusive Grenzen) */
brenndauerMin: number;
brenndauerMax: number;
/** (x, y): x zeigt nach Osten, y nach Norden; Richtung, in die der Wind weht */
windRichtung: readonly [number, number];
windStaerke: number;
loeschWahrscheinlichkeit: number;
/** Startzelle, null = zufällige Waldzelle */
start: Zelle | null;
maxSchritte: number;
seed: number;
}
export const STANDARD_PARAMS: Params = {
breite: 100,
hoehe: 100,
schneiseAnteil: 0.05,
schneisenZeilen: [],
schneisenSpalten: [],
brandfaktorMin: 0.1,
brandfaktorMax: 1.0,
brenndauerMin: 2,
brenndauerMax: 5,
windRichtung: [1, 0],
windStaerke: 0,
loeschWahrscheinlichkeit: 0.05,
start: null,
maxSchritte: 1000,
seed: 0,
};
export interface Gitter {
breite: number;
hoehe: number;
zustand: Uint8Array;
brandfaktor: Float32Array;
restbrenndauer: Uint16Array;
}
export type Zufall = () => number;
/** mulberry32: kleiner, schneller Zufallsgenerator mit Seed, liefert Zahlen in [0, 1) */
export function zufallsgenerator(seed: number): Zufall {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 1 + Windstärke * cos(Winkel zwischen Windvektor und Vektor zur Nachbarzelle) */
export function windfaktor(dr: number, dc: number, [wx, wy]: readonly [number, number], staerke: number): number {
const normWind = Math.hypot(wx, wy);
if (staerke === 0 || normWind === 0) return 1;
// Zeilenindex wächst nach unten, y zeigt nach oben
const nx = dc;
const ny = -dr;
return 1 + (staerke * (wx * nx + wy * ny)) / (normWind * Math.hypot(nx, ny));
}
/** Erzeugt Zustand, Brandfaktor und Restbrenndauer und entzündet die Startzelle. */
export function initGitter(params: Params, zufall: Zufall): Gitter {
const { breite, hoehe } = params;
const n = breite * hoehe;
const zustand = new Uint8Array(n);
for (let i = 0; i < n; i++) zustand[i] = zufall() < params.schneiseAnteil ? SCHNEISE : WALD;
if (params.start) {
// Am vorgegebenen Brandherd wächst immer Wald, auch wenn dort zufällig eine natürliche Schneise läge
zustand[params.start[0] * breite + params.start[1]] = WALD;
}
for (const zeile of params.schneisenZeilen) zustand.fill(SCHNEISE, zeile * breite, (zeile + 1) * breite);
for (const spalte of params.schneisenSpalten) {
for (let r = 0; r < hoehe; r++) zustand[r * breite + spalte] = SCHNEISE;
}
// Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise
zustand.fill(SCHNEISE, 0, breite);
zustand.fill(SCHNEISE, (hoehe - 1) * breite, n);
for (let r = 0; r < hoehe; r++) {
zustand[r * breite] = SCHNEISE;
zustand[r * breite + breite - 1] = SCHNEISE;
}
let start: number;
if (params.start === null) {
// Zufällige Waldzelle; hängt nur vom Seed und den Zelltypen ab, daher bei gleichem Seed
// in allen Varianten dieselbe, solange diese die Zelltypen nicht verändern
const waldzellen: number[] = [];
for (let i = 0; i < n; i++) if (zustand[i] === WALD) waldzellen.push(i);
if (waldzellen.length === 0) throw new Error("Keine Waldzelle für den Brandherd vorhanden");
start = waldzellen[Math.floor(zufall() * waldzellen.length)];
} else {
start = params.start[0] * breite + params.start[1];
if (zustand[start] !== WALD) {
throw new Error(`Startzelle (${params.start.join(", ")}) liegt auf einer künstlichen Brandschneise oder am Rand`);
}
}
const brandfaktor = new Float32Array(n);
const restbrenndauer = new Uint16Array(n);
const spanne = params.brandfaktorMax - params.brandfaktorMin;
const dauern = params.brenndauerMax - params.brenndauerMin + 1;
for (let i = 0; i < n; i++) {
// Für jede Zelle ziehen, damit Varianten mit gleichem Seed denselben Wald erhalten
const bf = params.brandfaktorMin + zufall() * spanne;
const dauer = params.brenndauerMin + Math.floor(zufall() * dauern);
if (zustand[i] !== SCHNEISE) {
brandfaktor[i] = bf;
restbrenndauer[i] = dauer;
}
}
zustand[start] = BRENNEND;
return { breite, hoehe, zustand, brandfaktor, restbrenndauer };
}
/** Ein synchroner Zeitschritt: liest nur das alte Gitter und gibt ein neues zurück. */
export function schritt(gitter: Gitter, params: Params, zufall: Zufall): Gitter {
const { breite, hoehe, zustand, brandfaktor } = gitter;
const neu: Gitter = {
breite,
hoehe,
zustand: zustand.slice(),
brandfaktor: brandfaktor.slice(),
restbrenndauer: gitter.restbrenndauer.slice(),
};
const windfaktoren = NACHBARN.map(([dr, dc]) => windfaktor(dr, dc, params.windRichtung, params.windStaerke));
for (let r = 0; r < hoehe; r++) {
for (let c = 0; c < breite; c++) {
const i = r * breite + c;
if (zustand[i] !== BRENNEND) continue;
if (zufall() < params.loeschWahrscheinlichkeit) {
neu.zustand[i] = GELOESCHT;
neu.brandfaktor[i] = 0;
neu.restbrenndauer[i] = 0;
continue;
}
for (let k = 0; k < NACHBARN.length; k++) {
const [dr, dc, gewicht] = NACHBARN[k];
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= hoehe || nc < 0 || nc >= breite) continue;
const j = nr * breite + nc;
if (zustand[j] !== WALD) continue;
if (zufall() < 0.5 * gewicht * brandfaktor[j] * windfaktoren[k]) neu.zustand[j] = BRENNEND;
}
neu.restbrenndauer[i] -= 1;
if (neu.restbrenndauer[i] === 0) {
neu.zustand[i] = ABGEBRANNT;
neu.brandfaktor[i] = 0;
}
}
}
return neu;
}
export type Anzahl = number[];
export function zaehle(zustand: Uint8Array): Anzahl {
const anzahl = new Array<number>(ZUSTAENDE.length).fill(0);
for (const z of zustand) anzahl[z]++;
return anzahl;
}
/** Zellen, die irgendwann gebrannt haben */
export function betroffen(anzahl: Anzahl): number {
return anzahl[BRENNEND] + anzahl[ABGEBRANNT] + anzahl[GELOESCHT];
}
/** Jede Zelle, die keine Brandschneise ist, war zu Beginn Wald */
export function waldflaeche(anzahl: Anzahl): number {
return anzahl.reduce((a, b) => a + b, 0) - anzahl[SCHNEISE];
}
export interface Ergebnis {
breite: number;
hoehe: number;
endzustand: Uint8Array;
/** Anzahl Zellen je Zustand und Zeitschritt, anzahl[0] = Anfangszustand */
anzahl: Anzahl[];
dauer: number;
betroffenAnteil: number;
}
/** Simuliert, bis keine Zelle mehr brennt oder maxSchritte erreicht ist. */
export function simuliere(params: Params): Ergebnis {
const zufall = zufallsgenerator(params.seed);
let gitter = initGitter(params, zufall);
const anzahl = [zaehle(gitter.zustand)];
while (anzahl[anzahl.length - 1][BRENNEND] > 0 && anzahl.length <= params.maxSchritte) {
gitter = schritt(gitter, params, zufall);
anzahl.push(zaehle(gitter.zustand));
}
const letzte = anzahl[anzahl.length - 1];
return {
breite: gitter.breite,
hoehe: gitter.hoehe,
endzustand: gitter.zustand,
anzahl,
dauer: anzahl.length - 1,
betroffenAnteil: betroffen(letzte) / waldflaeche(letzte),
};
}
function imInneren(gitter: Gitter, [r, c]: Zelle): boolean {
return r > 0 && r < gitter.hoehe - 1 && c > 0 && c < gitter.breite - 1;
}
/** Macht eine Waldzelle zur Brandschneise (interaktives Zeichnen). Verändert das Gitter direkt. */
export function setzeSchneise(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = SCHNEISE;
gitter.brandfaktor[i] = 0;
gitter.restbrenndauer[i] = 0;
return true;
}
/** Entzündet eine Waldzelle (interaktiv). Verändert das Gitter direkt. */
export function entzuende(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = BRENNEND;
return true;
}
+403
View File
@@ -0,0 +1,403 @@
:root {
color-scheme: light;
--hintergrund: #f4f3f0;
--flaeche: #fcfcfb;
--text: #0b0b0b;
--text-2: #52514e;
--rand: #dcdad4;
--raster: #e6e4df;
--akzent: #2a78d6;
--akzent-text: #ffffff;
--fehler: #c62828;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--hintergrund: #121211;
--flaeche: #1a1a19;
--text: #f0efec;
--text-2: #c3c2b7;
--rand: #34332f;
--raster: #2a2a28;
--akzent: #3987e5;
--fehler: #ef7b7b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--hintergrund);
color: var(--text);
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
[hidden] {
display: none !important;
}
h1,
h2,
h3 {
margin: 0 0 12px;
line-height: 1.25;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.1rem;
}
h3 {
font-size: 0.95rem;
margin-top: 20px;
color: var(--text-2);
}
p {
margin: 0 0 12px;
}
.kopf {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.kopf p {
margin: 0;
color: var(--text-2);
}
.reiter,
.werkzeug,
.experiment-wahl {
display: inline-flex;
border: 1px solid var(--rand);
border-radius: 8px;
overflow: hidden;
background: var(--flaeche);
}
.experiment-wahl {
margin-bottom: 12px;
}
.reiter__tab,
.werkzeug__option {
border: 0;
background: transparent;
color: var(--text);
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.reiter__tab + .reiter__tab,
.werkzeug__option + .werkzeug__option {
border-left: 1px solid var(--rand);
}
.reiter__tab.aktiv,
.werkzeug__option.aktiv {
background: var(--akzent);
color: var(--akzent-text);
}
.karte {
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: 12px;
padding: 16px;
min-width: 0;
}
.simulation {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"gitter parameter"
"verlauf parameter";
gap: 16px;
align-items: start;
}
.simulation__gitter {
grid-area: gitter;
}
.simulation__parameter {
grid-area: parameter;
}
.simulation__verlauf {
grid-area: verlauf;
}
@media (max-width: 860px) {
.simulation {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "gitter" "parameter" "verlauf";
}
}
.experimente {
display: grid;
gap: 16px;
}
.gitter {
display: block;
width: 100%;
max-width: 640px;
margin: 0 auto;
image-rendering: pixelated;
border-radius: 4px;
}
.gitter--interaktiv {
cursor: crosshair;
touch-action: none;
}
.legende {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 16px;
list-style: none;
padding: 0;
margin: 12px 0;
font-size: 0.875rem;
color: var(--text-2);
}
.legende li {
display: flex;
align-items: center;
gap: 6px;
}
.legende__farbe {
width: 12px;
height: 12px;
border-radius: 3px;
outline: 1px solid var(--rand);
}
.steuerung {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 8px;
margin-bottom: 12px;
}
.steuerung .werkzeug {
margin-left: auto;
}
.knopf {
border: 1px solid var(--rand);
background: var(--flaeche);
color: var(--text);
border-radius: 8px;
padding: 8px 14px;
font: inherit;
cursor: pointer;
}
.knopf:disabled {
opacity: 0.5;
cursor: default;
}
.knopf--primaer {
background: var(--akzent);
border-color: var(--akzent);
color: var(--akzent-text);
min-width: 88px;
}
.kennzahlen {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.kennzahlen div {
border: 1px solid var(--rand);
border-radius: 8px;
padding: 8px 12px;
}
.kennzahlen dt {
font-size: 0.8rem;
color: var(--text-2);
}
.kennzahlen dd {
margin: 0;
font-size: 1.15rem;
font-variant-numeric: tabular-nums;
}
.hinweis {
font-size: 0.875rem;
color: var(--text-2);
}
.feld {
display: grid;
gap: 4px;
margin-bottom: 12px;
font-size: 0.9rem;
}
.feld--inline {
margin-bottom: 0;
}
.feld__zeile {
display: flex;
gap: 8px;
}
.feld__zeile input {
flex: 1;
min-width: 0;
}
input[type="number"],
select {
font: inherit;
color: var(--text);
background: var(--flaeche);
border: 1px solid var(--rand);
border-radius: 8px;
padding: 7px 10px;
}
.feld--inline input {
width: 110px;
}
.regler {
display: grid;
gap: 2px;
margin-bottom: 10px;
font-size: 0.9rem;
}
.regler__kopf {
display: flex;
justify-content: space-between;
}
.regler output {
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.regler input {
width: 100%;
accent-color: var(--akzent);
}
progress {
width: 100%;
accent-color: var(--akzent);
}
.fehler {
color: var(--fehler);
}
.tabelle-rahmen {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th,
td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--rand);
white-space: nowrap;
}
th {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-2);
}
.beispiele {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.beispiele figure {
margin: 0;
}
.beispiele .gitter {
max-width: 400px;
}
.beispiele figcaption {
margin-top: 8px;
font-size: 0.875rem;
text-align: center;
color: var(--text-2);
}
.beispiele strong {
color: var(--text);
}
/* Recharts zeichnet SVG; Farben über CSS, damit sie dem Farbschema folgen */
.diagramm .recharts-cartesian-grid line {
stroke: var(--raster);
}
.diagramm .recharts-cartesian-axis-line {
stroke: var(--rand);
}
.diagramm .recharts-text {
fill: var(--text-2);
font-size: 12px;
}
.diagramm .recharts-legend-item-text {
color: var(--text) !important;
}
.diagramm .recharts-tooltip-cursor {
stroke: var(--text-2);
}
+25
View File
@@ -0,0 +1,25 @@
// Führt Experimente im Hintergrund aus, damit die Oberfläche bedienbar bleibt
import { EXPERIMENTE, vergleiche, type ExperimentName, type Statistik } from "./experimente";
export interface Auftrag {
experiment: ExperimentName;
laeufe: number;
seed: number;
}
export type Nachricht =
| { typ: "fortschritt"; fertig: number; gesamt: number }
| { typ: "ergebnis"; statistik: Statistik[] };
// Die Typen der DOM-Bibliothek beschreiben ein Fenster, nicht einen Worker
const kanal = self as unknown as {
onmessage: ((e: MessageEvent<Auftrag>) => void) | null;
postMessage(nachricht: Nachricht): void;
};
kanal.onmessage = ({ data: { experiment, laeufe, seed } }) => {
const statistik = vergleiche(EXPERIMENTE[experiment].varianten, laeufe, seed, (fertig, gesamt) =>
kanal.postMessage({ typ: "fortschritt", fertig, gesamt }),
);
kanal.postMessage({ typ: "ergebnis", statistik });
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
// Relative Pfade, damit die App auch unter einem Unterpfad wie /ue1 läuft
base: "./",
plugins: [react()],
});