Files
numolus/README.md
T
wkhandClaude Sonnet 5 ec85c371e8 IF-Funktion als Formel: echte Kurzschluss-Auswertung
Neue eingebaute Funktion IF(Bedingung; Dann; Sonst?), nutzbar in jeder
gewöhnlichen Zellformel (nicht nur in "Mit Schleife"-Funktionskörpern).

- functions.ts: IF als Platzhalter-Eintrag registriert, nur damit
  hasFunction('IF') greift (Namensreservierung -- ein Benutzer kann
  keine gleichnamige eigene Funktion mehr anlegen) und als defensiver
  Fallback. Trägt einen Kommentar, der auf die echte Implementierung
  verweist.
- evaluator.ts: die echte, kurzschließende Auswertung sitzt in
  evalScalar()s case 'func', VOR dem generischen eager-args-Pfad.
  evalIf() wertet nur die Bedingung immer aus, danach ausschließlich
  den genommenen Zweig -- der andere Zweig wird nie evaluiert, ein
  Fehler darin (z. B. =IF(B1=0; 0; A1/B1)) schlägt also nie durch.
  Gleiche Wahrheits-Konvention wie überall sonst im Engine (Zahl != 0
  bzw. nicht-leerer Text = wahr).
- collectRefs() bleibt unverändert: sein generischer case 'func' läuft
  bereits konservativ durch alle Argumente, unabhängig vom zur
  Laufzeit genommenen Zweig -- exakt wie Excels IF().

Tests: 8 neue (evaluator.spec.ts: beide Zweige, 2-/3-Argument-Form,
Kurzschluss-Beweis, Fehler in der Bedingung, Arity-Fehler, Vergleiche
als Bedingung; document.spec.ts: Namenskollision mit eingebautem IF).
257 -> 265 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert (inkl. Kurzschluss- und Neuberechnungsverhalten).

READMEs aktualisiert (Funktionen-Liste, Auswertungsmodell, Testzahl).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:00:45 +02:00

717 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Numbers-Studie — Tabellenkalkulation mit eigener Formel-Engine
Eine browserbasierte Tabellenkalkulation als **Case- und Designstudie** nach dem
Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
1. **Handwerk** — eine von Grund auf selbst geschriebene, schlanke Formel-Engine
(Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug-
Erkennung). Dokumentiert in [`src/engine/README.md`](src/engine/README.md).
2. **Produkt** — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine
**freie Fläche (Canvas)**, auf die man beliebig viele **Tabellen- und
Diagramm-Objekte** setzt, verschiebt, skaliert und befüllt.
```bash
npm install
npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 265 Tests
npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config)
```
Alles läuft **rein clientseitig** — kein Server, keine Netzwerk-Requests. Datei-
I/O über File API und Blob-URLs.
---
## Das Canvas-Modell
Wie in Numbers ist ein *Blatt* kein einzelnes Endlos-Raster, sondern eine
scroll- und zoombare Fläche, die **Objekte** trägt:
- **Tabellen-Objekt** — ein eigenes Gitter mit einem Namen, der **pro Blatt**
eindeutig ist (siehe „Formelgrammatik" unten für Bezüge über mehrere Blätter
mit demselben Tabellennamen). Frei positionierbar (Ziehen an der
Kopfleiste). Zwei Darstellungen:
- **Automatisch** — so groß wie die Zellen, alle Zeilen sichtbar; der SE-Griff
skaliert die Spaltenbreiten proportional.
- **Feste Größe** — fester Viewport, **intern scrollbar**, mit frei wählbarer
Zahl **fixierter Kopfzeilen und fixierter Spalten** (`position: sticky`
gegen den Scroll-Container). Der SE-Griff zieht dann den Viewport auf.
Spaltenbreite per Drag am Spaltenkopf (ab **24 px**), **Zeilenhöhe** per Drag
an der Unterkante des Zeilenkopfs (Standard + je Zeile).
- **Diagramm-Objekt** — Chart.js (Balken/Linie), frei positionier- **und**
skalierbar. Datenquelle = ein Tabellen-Objekt; jede Zelländerung aktualisiert
es sofort.
- **Text-Objekt** — ein formatierbares Textfeld als Zusatz zu Tabellen und
Diagrammen. Doppelklick zum Bearbeiten; Schriftgröße, Fett/Kursiv, Ausrichtung,
Text- und Hintergrundfarbe im Inspector; frei skalierbar.
Objekte kommen beim Anklicken in den Vordergrund, werden per `Entf` gelöscht
(Diagramm/Text) bzw. über das `×` der Kopfleiste, per Doppelklick auf den Namen
umbenannt. **Je Tabelle, Diagramm und Textfeld** sind eigene Hintergrund- und
Textfarbe einstellbar. Mehrere Blätter über die Tabs unten (hinzufügen,
umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
(`Strg/Cmd` + Mausrad, Zoom-Leiste oder Einstellungen).
### Zellauswahl
- Klick = eine Zelle, **Shift+Klick / Ziehen** = ein Rechteck. Das Ziehen ist
positions- statt event-target-basiert (`elementsFromPoint`), übersteht schnelle
Bewegungen und das Verlassen der Tabelle, scrollt bei festen Tabellen an der
Kante automatisch mit und heilt einen verlorenen `mouseup` selbst.
- Kopf-Klick = ganze Spalte / Zeile, Ecke = alles.
- **`Strg`/`Cmd`+Klick** = weitere Zelle **hinzufügen** (erneut = abwählen),
**`Strg`/`Cmd`+Ziehen** = weiteres Rechteck; auch am Spalten-/Zeilenkopf.
- Die **diskontinuierliche Mehrfachauswahl** trägt alle folgenden Aktionen:
Live-Aggregat (Summe/Mittel/Anzahl) in der Statusleiste, die
**Auswahl-Formatierung** im Inspector und `Entf` (leert alle markierten Zellen).
- Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die **aktive Zelle**
(blauer Rahmen, Formelleiste, `F2`/Tippen zum Bearbeiten) ist stets die
**zuerst** angeklickte/gesetzte Zelle einer Auswahl — sie bleibt beim
Ziehen/Erweitern eines Bereichs stehen, statt dem Cursor zu folgen (Excel-/
Numbers-Verhalten). Während einer Tab/Enter-Dateneingabefolge im Bereich
wandert sie mit dem Cursor mit.
- **Kopieren/Ausschneiden/Einfügen** (`Strg`/`Cmd`+`C`/`X`/`V`, Kontextmenü):
überträgt Zellinhalte **und** Formatierung. Einfügen auf eine Auswahl, die
ein exaktes Vielfaches der kopierten Größe ist, **kachelt**; eine kleinere
oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber
hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe
„`$`-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die
Kopierquelle (mit eigenem Rahmen um deren erste Zelle, bleibt stehen, auch
wenn die aktuelle Auswahl längst woanders ist), `Esc` verwirft den Puffer.
- **Ausfüllkästchen** (das kleine Quadrat unten rechts an der Auswahl, wie in
Excel/Numbers): ziehen wiederholt die Auswahl — Werte, Formeln
(bezugsangepasst) und Formatierung — in die überstrichenen Zellen. Nur eine
Achse gleichzeitig (rein runter/hoch/rechts/links, je nachdem wohin weiter
gezogen wird); ein mehrzeiliger/-spaltiger Ausgangsbereich wird zyklisch
wiederholt, auch über eine unvollständige letzte Wiederholung hinweg.
**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
| Taste | Verhalten |
|---|---|
| Pfeiltasten | eine Zelle; **Shift+Pfeil** erweitert die Auswahl |
| **`Strg`/`Cmd`+Pfeil** | zum Rand des Datenblocks springen (leer↔gefüllt) |
| **Tab / Shift+Tab** | rechts / links |
| **Enter / Shift+Enter** | runter / hoch — nach einer Tab-Folge **zurück zur Startspalte + eine Zeile** |
| bei aktivem **Bereich** | Tab/Enter **rotieren den Cursor im Bereich** (mit Umbruch), der Bereich bleibt markiert |
| Tippen / `F2` | Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich) |
| `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle |
| `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) |
| **`Strg`/`Cmd`+`C`/`X`/`V`** | Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst) |
| **`Strg`/`Cmd`+`Z`** · **`Strg`/`Cmd`+`Y`** / **`Strg`/`Cmd`+`Shift`+`Z`** | Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷) |
| **`F4`** (im Editor/in der Formelleiste) | Bezug am Cursor sperren/zyklisch umschalten: `A1` → `$A$1` → `A$1` → `$A1` → `A1`; bei markiertem Text werden **alle** darin gefundenen Bezüge auf einmal umgeschaltet, jeder ausgehend vom eigenen Zustand |
| **`Alt`+`Enter`** bzw. **`Cmd`+`Enter`** (im Editor) | Zeilenumbruch in der Zelle einfügen, statt zu committen — `Cmd`+`Enter` als Mac-Fallback, da `⌥` (Option) dort eine Compose-Taste ist und nicht überall zuverlässig als reines Modifier ankommt |
Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
Zeilen/Spalten).
**Rückgängig/Wiederholen**: dokumentweiter Undo-Stack (Snapshot-basiert, wie
der bereits vorhandene JSON-Export/Import-Round-Trip — einfach und
nachweisbar korrekt für ein Dokument dieser Größe, statt eines Patch-/
Kommando-Logs). Reine Auswahl-/Cursor-Bewegungen zählen nie als Schritt; eine
schnelle Folge von Mutationen (ein Spalten-/Zeilen-Resize-Drag feuert bei
jeder Mausbewegung) wird zu **einem** Schritt zusammengefasst, sofern sie
innerhalb von 150 ms aufeinanderfolgen — knapp unter normalem Tipp-/Klick-
Tempo, damit zwei tatsächlich getrennte schnelle Aktionen nicht verschmelzen,
aber weit über dem Abstand aufeinanderfolgender Drag-Ereignisse (~16–30 ms).
Ein neuer Bearbeitungsschritt verwirft sofort einen etwaigen Wiederholen-Ast.
„Auf Beispiel zurücksetzen" und JSON-Import „Ersetzen" starten eine **neue**
Historie (das vorherige Dokument ist danach nicht mehr zurückholbar), Undo/
Redo selbst tun das nicht. Symbolleiste zeigt zwei Buttons (↶ ↷, deaktiviert
wenn nichts zu tun ist); greift auch ohne Tabellenfokus, außer während eine
Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen
Undo dieses Feldes benutzen soll.
**In-Zellen-Editor**: das Eingabefeld liegt als Overlay über der Zelle (nicht
in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten
„sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit `Alt`+`Enter`
eingefügten Zeilenumbrüchen, bis zu einem Maximum, danach intern scrollbar.
Ein Zeilenumbruch bleibt auch nach dem Committen erhalten und wird in der
Zelle als solcher dargestellt (umbrechend statt einzeiliger Ellipse), auf die
Zeilenhöhe der Tabelle geklemmt.
**Formelleiste** (`fx`, oben): ein `<textarea>` statt eines einzeiligen Felds
— dadurch **umbricht** eine lange Formel statt seitwärts wegzuscrollen, und
an der unteren Kante lässt sie sich per **Maus** (natives `resize: vertical`)
auf beliebig viele Zeilen aufziehen, um mehr auf einmal zu sehen. `Alt`+`Enter`
bzw. `Cmd`+`Enter`, `F4` und `Esc` verhalten sich wie im In-Zellen-Editor. Das
Namensfeld links davon (ebenso das Pendant links in der **Statuszeile**, die
mit den Blatt-Reitern eine gemeinsame Zeile am unteren Rand teilt) zeigt bei
einer Mehrfachauswahl nicht nur die aktive Zelle, sondern den ganzen markierten
**Bereich** (`Umsatz::B2:E6`), bei einer diskontinuierlichen Auswahl zusätzlich
`+N` für die weiteren Rechtecke.
**Bezüge per Maus einfügen ("Point-Mode")**: sobald der Editor (Zelle oder
Formelleiste) eine Formel (`=…`) enthält, fügt ein **Klick** auf eine Zelle —
in derselben Tabelle **oder einer ganz anderen**, mit `Name!`-Qualifier bei
Bedarf `'in Quotes'` — deren Bezug an der Cursorposition ein; **Ziehen**
fügt stattdessen einen Bereichsbezug (`A1:C3`) ein, live wachsend während
der Zug. Ein weiterer Klick/Zug **ersetzt** den zuletzt eingefügten Bezug,
solange nichts dazwischen getippt wurde — **jede** Eingabe (ein `;` als
Argumenttrennzeichen ebenso wie jedes andere Zeichen) beendet dieses Fenster,
der nächste Klick fügt dann einen neuen Bezug an. Ein Klick während des
Point-Mode ändert nie die Tabellen-/Objektauswahl im Hintergrund.
### Benutzerdefinierte Funktionen
Der Button **„𝑓 Funktionen"** in der Symbolleiste öffnet die einzige Stelle, an
der eigene, dokumentweite Funktionen angelegt, bearbeitet und gelöscht werden
— kein JSON-Handedit, keine Konsole. Danach in jeder Zelle aufrufbar wie ein
eingebauter Funktionsname: `=MARGE(Umsatz!B2; Umsatz!C2)`.
- **Liste** zeigt jede Funktion als `NAME(param1; param2)` — exakt die
Aufruf-Syntax — mit **Bearb.** (öffnet dasselbe Formular, vorausgefüllt)
und **🗑** (löscht nach Bestätigung).
- **Formular** (Neu wie Bearbeiten): Name, eine dynamische Liste von
Parameter-Feldern (**+ Parameter**, `×` zum Entfernen; auch 0 Parameter
sind gültig — dann ist es eine benannte Konstante/Formel), und ein
Formel-Feld optisch identisch zur Formelleiste — **ohne** führendes `=`.
Ein Parametername verhält sich im Funktionskörper wie eine Zelle mit
eigenem Wert; er darf selbst nicht wie ein Zellbezug aussehen (`A1`, `$B2`
o. ä. sind ungültig). Ein Parameter kann auch ein **Bereich** sein, solange
er im Funktionskörper direkt an eine bereichsfähige Funktion übergeben wird
— z. B. `SPANNE(bereich) = MAX(bereich) - MIN(bereich)`, aufgerufen als
`=SPANNE(Umsatz!B2:E6)`; das Formular zeigt dieses Muster als Hinweis unter
dem Formel-Feld. Wie bei jeder Formel ergibt ein Bereich außerhalb einer
Funktionsargument-Position `#VALUE!`.
- **Validierung beim Speichern**, mit **Inline-Fehlermeldung** direkt im
Formular (kein Toast, nichts geht verloren): Name-Kollision mit einer
eingebauten oder einer anderen benutzerdefinierten Funktion, ungültiger
Name/Parameter, ein nicht parsender Formelkörper, und ein **Funktions-
Zyklus** (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt,
bevor je eine `#ERR`-Zelle entsteht.
- **Umbenennen/Löschen** aktualisiert **keine** bestehenden Formeltexte —
genau wie beim Umbenennen/Löschen einer Tabelle. Zellen, die die
betroffene Funktion aufrufen, zeigen danach `#NAME?`.
- Ein Aufruf wird **beim Parsen der aufrufenden Zelle in ihre AST eingesetzt**
(Inlining, keine Laufzeit-Closures) — siehe
[`src/engine/userFunctions.ts`](src/engine/userFunctions.ts) und die
Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung,
und auch Zellen, die *nur* im Funktionskörper referenziert werden (nicht im
Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
- Vollständig **undo-/redo-fähig** und Teil des JSON-Exports/-Imports.
#### Mit Schleife: echter Code statt einer einzelnen Formel
Der „Art"-Umschalter im Formular bietet neben **„Einfache Formel"** (s. o.)
auch **„Mit Schleife"** — echter Code mit Kontrollfluss, für Fälle, die eine
einzelne Formel nicht ausdrücken kann: über die Elemente eines Bereichs
selbst iterieren, verzweigen, vorzeitig abbrechen. Das Code-Feld akzeptiert
eine Folge aus:
- **Zuweisungen** — `name = ausdruck`.
- **FOR-Schleifen** — `FOR var IN bereich DO … END`, mit beliebig vielen
Anweisungen dazwischen (auch weiteren Schleifen/`IF`s); `bereich` ist ein
Parameter, eine Zelle oder ein Bereich (kein berechneter Ausdruck).
- **IF/ELSE** — `IF bedingung THEN … END` bzw. mit `ELSE …` davor dem `END`,
verschachtelbar in **jedem** Block (auch innerhalb eines `FOR`-Körpers).
- **RETURN** — `RETURN ausdruck`, an **beliebiger Stelle**, auch mitten in
einer Schleife oder einem `IF`-Zweig. Bricht die Ausführung sofort mit
diesem Wert ab; nichts danach läuft noch, auch keine weiteren
Schleifen-Durchläufe.
— beliebig gemischt und verschachtelt. Jeder eingeführte Name (Zuweisungsziel
oder Schleifenvariable) bleibt für den **restlichen** Code sichtbar, auch
nach dem Ende „seines" `FOR`/`IF` (mit seinem zuletzt zugewiesenen Wert) —
flacher Namensraum, kein Block-Scoping. `FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/
`ELSE`/`RETURN` sind reservierte Wörter, nicht als Parameter-/Variablenname
erlaubt. Für `IF`-Bedingungen gibt es Vergleichsoperatoren
(`= <> > < >= <=`, Ergebnis `1`/`0`) — in **jeder** Formel nutzbar, nicht
nur in Blöcken.
Beispiel — Produkt aller Elemente eines Bereichs (`PRODUCT` gibt es nicht als
eingebaute Funktion, nur `SUM`/`AVG`/`MIN`/`MAX`/`COUNT`):
```
ergebnis = 1
FOR x IN bereich DO
ergebnis = ergebnis * x
END
RETURN ergebnis
```
aufgerufen als `=PRODUCT(Umsatz!B2:E6)`. Ein zweites Beispiel mit `IF` und
frühem `RETURN` — die erste Zahl in einem Bereich über einer Schwelle finden,
ohne den Rest des Bereichs überhaupt anzufassen:
```
FOR x IN bereich DO
IF x > schwelle THEN
RETURN x
END
END
RETURN 0
```
**Kein Endlos-Risiko trotz echtem Kontrollfluss.** Anders als die einfache
Formel-Variante (die vollständig zu einer statischen Formel „inlined" wird)
läuft eine `FOR`/`IF`/`RETURN`-Funktion bei jeder Neuberechnung durch einen
kleinen, echten Interpreter (`evaluator.ts`) — nötig, weil ein `IF`-Zweig
bzw. ein frühes `RETURN` vom tatsächlichen Zellwert abhängt, nicht vorab
berechenbar ist. Eine `FOR`-Schleife über insgesamt mehr als 10.000 Zellen
(über alle Schleifen einer Funktion) ergibt `#ERR` statt zu hängen; eine
Funktion, deren Ausführung endet, ohne dass `RETURN` je gefeuert hat,
ebenso — das Formular warnt davor zusätzlich schon **beim Speichern**, wenn
nicht erkennbar jeder Codepfad ein `RETURN` erreicht. Details:
[`src/engine/README.md`](src/engine/README.md)s „Schleifen"-Abschnitt.
### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
Inspector unter **Objekt · Auswahl**: horizontale **Ausrichtung**
(links/mitte/rechts),
**Zahlenformat** (`Automatisch`, `Ohne`, Ganzzahl, 1/2 Nachkommastellen,
**Prozent** 0/2 Stellen — kombiniert mit der Locale unten), **Zell-Textfarbe**,
**Zell-Füllfarbe**, **Fett**/**Kursiv**, „Formatierung entfernen". Auch exakte
**Spaltenbreite** und **Zeilenhöhe** der Fokuszelle.
### Einstellungen (⚙, links neben dem Titel)
Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade
([`SettingsPanel.vue`](src/components/SettingsPanel.vue)), die die Fläche zur
Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle
wichtigen Punkte:
- **Objekt** (das gewählte):
- Tabelle → Name, Zeilen/Spalten (exakt), Darstellung, Viewport-Größe,
fixierte Kopfzeilen/Spalten, Position, Zahlenformat-Locale, **Standard-
Zeilenhöhe**, **Tabellen-Hintergrund/-Textfarbe**, und – bei aktiver
Zellauswahl – die **Auswahl**-Formatierung (s. o.).
- Diagramm → Name, Ansicht, Quelltabelle, Größe, Position, Zahlenformat-Locale,
**Hintergrund**, **Text-/Achsenfarbe**.
- Textfeld → Name, Text, Schriftgröße, Fett/Kursiv, Ausrichtung, **Textfarbe**,
**Hintergrund**, Größe, Position.
- **Blatt**: Name, Zahlenformat, **Hintergrundfarbe**, **Schriftfarbe**,
**Raster ein-/ausblenden** (`↺` setzt eine Farbe auf den Theme-Wert zurück),
neues Blatt, Blatt löschen.
- **Ansicht**: Zoom-Regler (**pro Blatt** gespeichert).
- **Dokument**: Erscheinungsbild (Auto/Hell/Dunkel), Zahlenformat
(Ländereinstellung), CSV-Standard-Trennzeichen, „Auf Beispiel zurücksetzen".
### Blatt-Erscheinungsbild
Jedes Blatt kann eine eigene **Hintergrundfarbe** (Canvas-Fläche hinter den
Objekten) und **Schriftfarbe** (Standard-Textfarbe der Gitter/Labels) haben und
das **Punkt-Raster** ein- oder ausblenden. Die Farben überschreiben die
Theme-Variablen `--bg` / `--text` nur für dieses Blatt; ohne Wert gilt das
Theme. Der Canvas-**Zoom** wird ebenfalls pro Blatt gemerkt.
Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` /
`Sheet`) und werden damit in **localStorage gespeichert und in `.json`
exportiert** – Round-Trip verlustfrei, ungültige Werte werden beim Import
verworfen bzw. geklemmt.
### Zahlenformat (Ländereinstellung)
Die Darstellung von Zahlen (Dezimal- und Tausendertrennzeichen) ist eine
**Kaskade**: Objekt → Blatt → Dokument. Jede Ebene kann die darüberliegende
überschreiben oder „erben" (leere Auswahl). Auswahl: `Ohne Trennzeichen`,
`de-DE`, `de-CH`, `en-US`, `en-GB`, `fr-FR`, `it-IT`. Wirkt auf Zellwerte, das
Live-Aggregat der Statusleiste und die Wertelabels der Diagramme. **CSV-/XLSX-
Export bleibt bewusst locale-neutral** (`.`-Dezimal, keine Gruppierung) für einen
verlustfreien Round-Trip; der CSV-*Import* erkennt beide Schreibweisen ohnehin.
Das vorbefüllte „Umsatz" ist bereits als **feste, scrollbare Tabelle mit
fixierter Kopfzeile und fixierter Spalte A** angelegt, um das Feature zu zeigen.
---
## Formelgrammatik (EBNF)
```ebnf
formula = "=" expression ;
expression = comparison ;
comparison = additive [ ("="|"<>"|">"|"<"|">="|"<=") additive ] ; (* höchstens
ein Vergleich, nicht
verkettbar wie
"a < b < c"; vor allem
für IF-Bedingungen in
„Mit Schleife"-Code
(s. o.), aber in jeder
Formel nutzbar *)
additive = term { ("+" | "-") term } ; (* links-assoziativ *)
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
unary = ("+" | "-") unary
| power ;
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
stärker als unäres Minus,
d.h. -2^2 = -(2^2) = -4 *)
primary = NUMBER | STRING | "(" expression ")" | funcall | reference ;
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
wenn direkt gefolgt von
einem vollen "Tabelle::"
(3-teilige Form); ein
einzelnes "X!Y" ohne
folgendes "::" bleibt
die alte 2-teilige Form
(X = Tabelle) *)
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED = /' ( [^'] | '' )* '/ ; (* Name mit Leerzeichen *)
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
```
Präzedenz schwach → stark: Vergleich (`= <> > < >= <=`) < `+ -` < `* /` <
unäres `+ -` < `^`.
Der (innere) Qualifier ist der **Tabellenname**. `!` und der Numbers-Stil `::`
sind dabei austauschbar und **case-/whitespace-tolerant**:
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`.
**Tabellennamen sind nur pro Blatt eindeutig**, nicht dokumentweit — zwei
verschiedene Blätter dürfen je eine Tabelle „Umsatz" haben (beim Anlegen/
Umbenennen wird nur gegen das **eigene** Blatt geprüft). Ein unqualifizierter
Bezug wie `Umsatz!B2` (ohne Blattangabe) löst sich dann wie folgt auf:
1. Gibt es die Tabelle nur auf **einem** Blatt (der Normalfall), wird sie
genommen — unverändert wie zuvor.
2. Gibt es sie auf **mehreren** Blättern, gewinnt die Tabelle auf **demselben
Blatt wie die Formel selbst** (nicht: dem gerade angezeigten Blatt in der
UI — das spielt für die Formel keine Rolle).
3. Bleibt es danach noch mehrdeutig, ergibt sich `#REF` — hier hilft die
explizite **3-teilige Form** `Blatt!Tabelle::Zelle`, z. B.
`=Analyse!Umsatz::B2` oder mit Leerzeichen `='Blatt 2'!Umsatz::B2`, die
**immer** genau die Tabelle auf dem genannten Blatt trifft, unabhängig
davon, wo die Formel selbst steht. Die 2-teilige Form (`Umsatz!B2` /
`Umsatz::B2`) bleibt für jede bestehende Formel exakt gültig — die
3-teilige Form ist rein additiv.
Die Formel-**Point-Mode**-Einfügung (s. o.) erkennt eine mehrdeutige
Zieltabelle selbst und fügt automatisch die volle 3-teilige Form ein, statt
einer Formel, die versehentlich die falsche Tabelle träfe.
**`$`-Bezüge sperren** (relativ/gemischt/absolut): `A1` (relativ) vs. `$A1` /
`A$1` (Spalte bzw. Zeile gesperrt) vs. `$A$1` (beide gesperrt). Für die
Auswertung selbst ist `$` irrelevant — sie ist rein positionsbasiert. Es zählt
beim **Kopieren**: eine gesperrte Achse bleibt exakt wie eingegeben stehen,
eine ungesperrte verschiebt sich um das Einfüge-Delta (siehe „Kopieren/
Ausschneiden/Einfügen" oben). Beim **Ausschneiden** gilt das nicht — dort
verschiebt sich die referenzierte Zelle selbst, also folgt jede Referenz
darauf, `$` hin oder her. `F4` schaltet den Bezug unter dem Cursor durch die
vier Kombinationen.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`, `IF(Bedingung; Dann; Sonst?)` — plus alle über
**„𝑓 Funktionen"** angelegten benutzerdefinierten Funktionen (siehe
„Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch
verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder `Name!…`),
Zahl-Literale — z. B. `=SUM(Umsatz!B2:E2; 100; A1)`.
`IF` ist die einzige Funktion, die ihre Argumente **nicht** vorab auswertet:
nur die Bedingung wird immer berechnet, danach ausschließlich der tatsächlich
genommene Zweig — der andere Zweig wird gar nicht ausgewertet, ein Fehler
darin (z. B. `=IF(B1=0; 0; A1/B1)`, wenn `B1` `0` ist) schlägt also nie
durch. Die Bedingung nutzt dieselbe Zahl-Semantik wie die Vergleichsoperatoren
`= <> > < >= <=` (Ergebnis `1`/`0`, keinen eigenen Wahrheitstyp): jede
Zahl außer `0` sowie jeder nicht-leere Text gilt als wahr, z. B.
`=IF(A1>=100; "groß"; "klein")`. Ohne Sonst-Zweig liefert eine falsche
Bedingung `0`. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name
kollidiert mit der eingebauten Funktion.
**Fehlermodell** (propagiert an abhängige Zellen):
`#REF` (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein
unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch
über die „gleiches Blatt wie die Formel"-Regel nicht eindeutig wird — siehe
oben), `#DIV/0!`,
`#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR`
(Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten
Funktion, oder bei „Mit Schleife"-Code eine ungültige FOR-Quelle, mehr als
10.000 Schleifenschritte, oder eine Ausführung ohne je erreichtes `RETURN`),
`#CIRC` (Zirkelbezug, auch tabellenübergreifend), `#VALUE!`
(Typfehler in Arithmetik — *dokumentierte Erweiterung*). Referenz auf leere
Zelle = `0` in Arithmetik, von `COUNT` nicht gezählt. Ein Zyklus zwischen
benutzerdefinierten Funktionen selbst wird nicht über `#CIRC` sichtbar — er
wird schon beim **Speichern** der Funktion im UI abgelehnt (kein neuer
Fehlercode nötig).
---
## Architektur der Engine-Module
Framework-agnostisches ES-Modul unter [`src/engine/`](src/engine/) — **keine**
Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit
ist eine **Tabelle**; der Abhängigkeitsgraph läuft dokumentweit über alle
Tabellen aller Blätter.
| Datei | Aufgabe |
|-------|---------|
| `lexer.ts` | Tokenizer, flacher Token-Strom (inkl. `!` und `::`). |
| `parser.ts` | Recursive-Descent → AST; EBNF im Dateikopf; `collectRefs()`. |
| `evaluator.ts` | AST + `EngineHost` → Wert; Fehler-Propagierung. |
| `graph.ts` | `DependencyGraph`: `affected()`, `topoOrder()`, Zirkel-DFS. |
| `refs.ts` | A1 ↔ `(col,row)`, Bereichs-Expansion. |
| `functions.ts` | `{ NAME → fn(values) }` für SUM/AVG/MIN/MAX/COUNT. |
| `userFunctions.ts` | Benutzerdefinierte Funktionen: `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure. Ein `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) wird stattdessen zu einem `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen; sein eigener Kontrollfluss läuft erst zur Auswertungszeit, im kleinen Interpreter in `evaluator.ts` (echte Zellwerte nötig für `IF`/frühes `RETURN`). |
| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`; verdrahtet `userFunctions.ts` direkt nach `parseFormula()`. |
**Neuberechnung.** `setCell` reparst eine Zelle → `graph.affected(dirty)` →
`graph.topoOrder()` → Zyklus-Zellen `#CIRC`, Rest topologisch auswerten, nicht
betroffene Zellen behalten ihren Wert.
**Anbindung an Vue.** [`src/store/document.ts`](src/store/document.ts) ist das
„eigene `reactive()`-Modul": es hält eine `Workbook`-Instanz und einen
`recalcTick`-Ref. Die UI liest `store.cellValue(...)`, das `recalcTick` berührt —
nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also
**wie ein `computed`**.
---
## Selbst gebaut vs. Library
| Selbst gebaut | Library |
|---------------|---------|
| **Formel-Engine komplett** (Lexer, Parser, AST, Evaluator, dokumentweiter Abhängigkeitsgraph, Topo-Sort, Zirkel-Erkennung, Funktionen) | **Vue 3** + Vite |
| **Die Canvas**: Objekte platzieren, verschieben, skalieren, Z-Order, Zoom | **Chart.js** (Balken + Linie) |
| **Das Raster** je Tabelle: Auswahl (Klick/Shift/Drag), Tastaturnavigation, In-Zellen-Edit, Spaltenbreite per Drag | **xlsx (SheetJS)** — nur `.xlsx` I/O, lazy geladen, nie zur Auswertung |
| Reaktiver **Store** / Dokumentmodell (v1 → v2 Migration) | — |
| **CSV-Parser + -Writer** (RFC 4180, Quoting, Delimiter-Sniffing, Locale-Zahlen) | |
| **JSON**-Serialisierung + Schema-Validierung | |
| Sheet-Tabs (Drag-Sortierung), Kontextmenü, Modals, Toasts, Theme | |
Bewusst **nicht** verwendet: Handsontable, AG Grid, Univer, Luckysheet,
HyperFormula, formula-parser. `@vueuse/core`/`splitpanes` (in der Aufgabe als
optionale Komfort-Libs genannt) wurden nach dem Umbau auf das Canvas-Modell nicht
mehr gebraucht und wieder entfernt, um den Abhängigkeitsbaum ehrlich zu halten.
---
## Import / Export
Alles clientseitig (`Blob` + `URL.createObjectURL` + `<a download>` + revoke).
### Export
| Format | Inhalt |
|--------|--------|
| `.json` | **Natives Format**, verlustfreier Round-Trip: alle Blätter, alle Tabellen-/Diagramm-Objekte mit Position & Größe, Rohformeln, Spaltenbreiten, Tab-Reihenfolge, aktives Objekt, Zellauswahl. |
| `.csv` | Aktive Tabelle, **berechnete Werte**, RFC-4180-Quoting, Trennzeichen `,`/`;` wählbar, `\r\n`, optional UTF-8-BOM. |
| `.xlsx` | Arbeitsmappe via SheetJS, **ein Worksheet je Tabellen-Objekt**. Formeln als echte Excel-Formeln (`;`→`,`, `AVG`→`AVERAGE`), immer mit gecachtem Wert. |
| `.png` | Gewähltes Diagramm-Objekt über `chart.toBase64Image()`. |
### Import
`<input type="file">` **und** Drag & Drop auf die Canvas; `.json` `.csv` `.xlsx`.
- **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*.
**v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2
migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema
wird validiert; bei Fehler klare Meldung, **kein Teil-Import**.
- **`.csv`** → „als neue Tabelle (aktuelles Blatt)" / „aktive Tabelle ab A1
ersetzen". Trennzeichen-Sniffing (+ Override). Locale-Erkennung
(`1.234,56` ↔ `1,234.56`). Werte mit führendem `=` werden zu Formeln.
- **`.xlsx`** → je Worksheet ein neues Blatt mit einer Tabelle.
- Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh.
Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**.
## Persistenz
Das gesamte Dokument wird (debounced) nach `localStorage` gespiegelt, robust
gegen leeren/kaputten State — Fallback ist das Seed-Dokument. Ein v1-State im
Storage wird beim Laden migriert. Button **„Zurücksetzen"** stellt das
Seed-Dokument wieder her.
## Vorbefülltes Dokument
Blatt **„Analyse"** mit drei Tabellen-Objekten (**Umsatz**, **Kosten**,
**Übersicht**) und einem **Diagramm** (Umsatz je Region), plus ein leeres Blatt
**„Blatt 2"**. „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier
Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
`E2 = =D2/B2*100`. Siehe [`src/data/seed.ts`](src/data/seed.ts).
> **Hinweis zur Aufgabenstellung:** Die Akzeptanzliste nennt „Übersicht!B2 =
> 6070". Die explizit angegebene Nord-Zeile ergibt jedoch
> `1200+1450+1600+1720 = 5970`. Die Studie hält sich an die explizite Tabelle;
> das demonstrierte Verhalten (Sofort-Propagierung durch Übersicht, „Gesamt" und
> das Diagramm bei Änderung von `Umsatz!B2`) ist davon unberührt.
## Tests
`npm run test` — Vitest, 265 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
Bereiche, `;`-Argumente, Parse-Fehler.
- **refs (`$`-Sperrachsen, `F4`)** — `parseA1`/`toA1` runden relativ/absolut/
gemischt; `cycleReferenceLocks` durchläuft die vier Kombinationen, trifft
bei Cursor auf einem Bereich die richtige Seite, liefert `null` ohne
Treffer; bei echter Textauswahl schaltet es **alle** überlappten Bezüge auf
einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die
wie eine Zelle aussehen (`AB12!A1`), unangetastet.
- **evaluator** — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias,
Cross-Table, Case-/`::`-Toleranz, jeder Fehlerfall, Fehler-Propagierung,
`IF` (beide Zweige, 2-/3-Argument-Form, Kurzschluss-Beweis, Vergleiche als
Bedingung, Fehler in der Bedingung).
- **graph** — `affected`, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
- **workbook / store** — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch
die ganze Kette, `#REF` nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt
mit Cross-Sheet-Formel, `resetToSeed`, Live-Auswahl-Aggregat,
Neuberechnungs-**Reihenfolge**, **v1 → v2 Migration**.
- **Tabellen-Ansicht** — Seed-Umsatz ist fixiert/scrollbar; `setTableView`
klemmt fixierte Zeilen/Spalten an die Gittergröße; Verkleinern löscht
Zellen außerhalb und klemmt nach; Ansichts-Einstellungen im JSON-Round-Trip.
- **Zahlenformat-Kaskade** — `cellDisplay` nutzt Dokument-Locale; Blatt-Override
schlägt Dokument, Objekt-Override schlägt Blatt; Zurücksetzen fällt die
Kaskade hinab; Overrides im JSON-Round-Trip, ungültige Werte werden verworfen.
- **Blatt-Erscheinungsbild** — Hintergrund/Schrift/Raster sind pro Blatt und
löschbar; Zoom pro Blatt (folgt dem Tab); Erscheinungsbild + Zoom +
CSV-Default im JSON-Round-Trip, kaputte Farb-/Zoom-Werte verworfen bzw.
geklemmt.
- **Zell-Formatierung** — per-Zelle Zahlenformat × Locale (inkl. Prozent);
`setRangeFormat` über einen Bereich, Löschen per Falsy/`auto`; `selectionFormat`
meldet nur gemeinsame Werte; Zeilenhöhe (Standard + je Zeile), Spaltenbreite ab
24 px; Verkleinern pruned `formats`/`rowHeights`; alles im JSON-Round-Trip.
- **Text-Objekte** — einfügen/bearbeiten/formatieren + Round-Trip; kaputtes
Text-Objekt im Import wird abgelehnt, Dokument bleibt unverändert.
- **Objekt-Farben** — je Tabelle/Diagramm/Textfeld eigenes `bg`/`fg`, löschbar,
im JSON-Round-Trip.
- **Mehrfachauswahl** — `addToSelection` behält frühere Rechtecke & verschiebt
die aktive Zelle, erneutes Klicken schaltet eine 1×1-Auswahl ab,
`extendActiveRange` wächst das jüngste Rechteck; Aggregat & Formatierung über
**alle** Rechtecke; `#`-genaue Zellzahl; JSON-Round-Trip inkl. `ranges`,
Klemmung beim Verkleinern; die aktive Zelle bleibt beim Erweitern eines
Bereichs der **Anker** (erste Zelle), nicht die zuletzt erreichte.
- **Zwischenablage** — Kopieren passt jede relative Referenz um das Einfüge-
Delta an, `$`-gesperrte Achsen bleiben stehen; Einfügen kachelt eine exakt
passende größere Auswahl bzw. verankert/überläuft eine kleinere; Formatierung
wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz `$` der
verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen
unangetastet und leert die Quelle; `Esc` verwirft den Puffer.
- **Ausfüllkästchen** — füllt runter/rechts mit bezugsangepassten Formeln;
ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei
einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links;
Formatierung wird mitkopiert.
- **Rückgängig/Wiederholen** — Einzel-Edit rückgäng-/wiederholbar; mehrere
Edits schrittweise in der richtigen Reihenfolge zurück; ein neuer Edit nach
einem Undo verwirft den Wiederholen-Ast; reine Auswahländerungen sind nie
ein Schritt; deckt Kopieren/Einfügen, Ausfüllen und Formatierung ab (nicht
nur `setCellInput`); „Auf Beispiel zurücksetzen" beginnt eine neue Historie.
- **Formel-Point-Mode** — Klick fügt einen lokalen Bezug an der Cursorposition
ein; ein zweiter Klick ohne Zwischen-Tippen ersetzt ihn, mit Tippen dazwischen
hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick
in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen);
ohne Formel im Puffer greift nichts.
- **Benutzerdefinierte Funktionen** — Engine-Ebene (`userFunctions.spec.ts`):
Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert,
auch bei kollidierenden Parameternamen zwischen den Scopes), Arity-Mismatch →
`#ERR`, unbekannter Name → `#NAME?`, Rekursionstiefen-Sicherheitsnetz,
Abhängigkeits-Edges für Zellen, die nur im Funktionskörper vorkommen (nicht
im Aufruf) — inkl. korrekter Neuberechnungs-Reihenfolge; ein Bereichs-
Parameter funktioniert in Funktionsargument-Position (inkl. Neuberechnung
bei Änderung einer Zelle *innerhalb* des übergebenen Bereichs) und ergibt
`#VALUE!` außerhalb davon. Store-Ebene: Name-/
Parameter-Validierung, Kollision mit eingebauten/anderen Funktionen,
Zyklus-Ablehnung beim Speichern (direkt und über mehrere Stufen), Umbenennen/
Löschen lässt bestehende Aufrufe zu `#NAME?` werden, Undo/Redo einer
Funktionsänderung, voller JSON-Round-Trip, `customFunctions: []` bei
v1→v2-Migration.
- **Vergleichsoperatoren** — alle sechs (`= <> > < >= <=`), Präzedenz
unterhalb `+ -`, nicht verkettbar (`a < b < c` ist ein Parse-Fehler).
- **„Mit Schleife"-Code** — Parser-Ebene (`parseFunctionBlock`): ein
`FOR...DO...END` mit mehreren Anweisungen, mehrere unabhängige Schleifen
nacheinander, `IF...THEN...END` und `IF...THEN...ELSE...END`, `IF`
verschachtelt in `FOR` mit einem `RETURN` mittendrin, `FOR` verschachtelt
in `FOR`, `RETURN` an beliebiger Stelle mit beliebigem Ausdruck, ein Name
aus einer früheren Anweisung in einer späteren Schleife nutzbar, ein Block
ganz ohne `RETURN` parst trotzdem (das ist eine Save-/Laufzeit-Prüfung,
kein Parse-Fehler); Fehler bei `FOR`/`IF` ohne passendes `END`/`DO`/`IN`/
`THEN`, fehlendem `=`, Selbstbezug im eigenen Initialisierer. Der
Interpreter (`evaluator.ts`, Workbook-Integration in
`userFunctions.spec.ts`): korrekter Wert und Neuberechnung bei Änderung
einer Zelle *innerhalb* des übergebenen Bereichs, ein `FOR`-Körper mit
mehreren Anweisungen läuft jede Iteration vollständig, `IF`/`ELSE`
verzweigt nach dem echten, aktuellen Zellwert, ein frühes `RETURN`
innerhalb eines `IF` innerhalb eines `FOR` bricht die Schleife wirklich ab
(eine erst *nach* dem Treffer geänderte Zelle bleibt folgenlos), eine Zelle,
die nur in einem **nicht** genommenen `IF`-Zweig steht, ist trotzdem eine
erfasste Abhängigkeit, verschachtelte `FOR`-Schleifen rechnen korrekt (nicht
nur parsen), ein verschachtelter Aufruf einer anderen Funktion im
Schleifenkörper wird mit-expandiert, Arity-Prüfung gilt auch hier, mehr als
10.000 Schleifenschritte (über alle Schleifen einer Auswertung) sowie eine
FOR-Quelle, die nach Parametersubstitution kein Zell-/Bereichsbezug ist,
sowie eine Ausführung ohne je erreichtes `RETURN` ergeben alle `#ERR` statt
eines Absturzes. Store-Ebene: reservierte Wörter (`FOR`/`IN`/`DO`/`END`/
`IF`/`THEN`/`ELSE`/`RETURN`) als Parameter- oder lokaler Name abgelehnt (auch
wenn der lokale Name in einem verschachtelten `IF`-Zweig deklariert wird),
zellartiger lokaler Name abgelehnt, Wiederverwendung desselben lokalen
Namens über mehrere Anweisungen (das Akkumulator-Muster) ist **keine**
Kollision, ein lokaler Name, der einen Parameter überschattet, wird
abgelehnt, eine berechnete FOR-Quelle (auch verschachtelt in einem `IF`)
wird abgelehnt, ein Codepfad ohne garantiertes `RETURN` (fehlt ganz, oder
nur ein `IF`-Zweig ohne `ELSE` returnt) wird beim Speichern abgelehnt — mit
`ELSE`, bei dem **beide** Zweige returnen, wird akzeptiert — Funktions-Zyklus
durch einen Schleifenkörper wird erkannt, Undo/Redo, `kind` im
JSON-Round-Trip, ein älteres gespeichertes Dokument ohne `kind`-Feld
defaultet auf `'expression'`.
- **Blatt-Qualifier „Blatt!Tabelle::Zelle"** — Parser-Ebene: die explizite
3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle
Rückwärtskompatibilität der 2-teiligen Form (`X!Y`, `X::Y`, auch ein
zweites `!` statt `::`), Fehler bei fehlender Zelle nach `::`. Workbook-
Ebene: zwei gleichnamige Tabellen auf verschiedenen Blättern lösen ein
unqualifiziertes `Tabelle!Zelle` zum **gleichen Blatt wie die Formel**
auf, die explizite Form trifft immer das genannte Blatt unabhängig vom
Formel-Standort, ein unbekanntes Blatt/eine weiterhin mehrdeutige
Tabelle ergibt `#REF`, Neuberechnung folgt korrekt der Auflösung. Store-
Ebene: Tabellennamen sind nur noch pro Blatt eindeutig (Anlegen/
Umbenennen prüft nur gegen das eigene Blatt), Point-Mode fügt die volle
3-teilige Form ein, sobald ein Klick auf eine mehrdeutig benannte Tabelle
auf einem *anderen* Blatt trifft.
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
`=1+2`→3, Locale-Zahlen.
---
## Bekannte Grenzen
- **Zeilen/Spalten nur am Rand.** Einfügen/Löschen *innerhalb* eines Gitters (mit
automatischem Umschreiben relativer Bezüge) ist bewusst nicht implementiert.
- **Objekt-Bezüge beim Anhängen.** Beim JSON-*Anhängen* zweier Dokumente werden
nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein
angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der
erste Treffer. „Ersetzen" (der Round-Trip-Fall) ist davon nicht betroffen.
- **Kein Virtual Scrolling** — „Feste Größe" scrollt intern, aber alle Zeilen
bleiben im DOM (nur geclippt); sehr große Tabellen/Importe (viele Tausend
Zeilen) werden träge.
- **Große Dateien** werden per `setTimeout`-Yield verarbeitet (UI bleibt
reaktiv), **kein** echter Web Worker.
- **xlsx-Formel-Übernahme** ist heuristisch (Separator-Swap unter Beachtung von
Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
- Kein Text-Verkettungs-/Vergleichsoperator, keine benannten Bereiche (nur
benutzerdefinierte *Funktionen*, keine benannten *Zellen/Bereiche*), kein
Snapping der Canvas-Objekte.
- **Benutzerdefinierte Funktionen** (Phase 1, siehe oben) bewusst ohne:
Autovervollständigen des Funktionsnamens im Editor, Live-Vorschau des
Ergebnisses im Bearbeiten-Dialog, variadische Parameter/Default-Werte, und
automatisches Nachführen bestehender Aufrufe bei Umbenennung (Verhalten
entspricht bewusst dem einer Tabellen-Umbenennung, s. o.).
- **„Mit Schleife"-Code** bewusst ohne: verkettete Vergleiche (`a < b < c`,
nur ein Vergleich pro Ausdruck), Text-Vergleich (Vergleichsoperatoren
bleiben numerisch), `AND`/`OR`/`NOT` für zusammengesetzte Bedingungen,
`BREAK`/`CONTINUE` (nur `RETURN` als Kontrollfluss-Ausstieg), echtes
Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar).
Verschachtelte Schleifen/`IF`s und `RETURN` an beliebiger Stelle sind
dagegen unterstützt (s. o.).
## Projektstruktur
```
src/
engine/ # Formel-Engine (Eigenbau) + Unit-Tests
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
# TableGrid, ChartView, TextBox, SheetTabs, StatusBar, TheLogo, …
numolus-logo.svg # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug
config/brand.ts # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste
composables/ # Theme, Toasts
data/seed.ts # vorbefülltes Dokument
styles/main.css # Design-Tokens (Light/Dark)
```