Files
numolus/README.md
T
wkhandClaude Sonnet 5 c3fde283c8 Add user-defined functions, editable exclusively via a new UI
Document-wide, named formula functions (e.g. MARGE(revenue; cost) =
(revenue - cost) / revenue) callable from any cell like a built-in
(=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through
a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form),
never via JSON handedit.

Engine: a call is inlined into the caller's AST at parse time
(src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and
graph.ts need no changes -- dependencies on cells referenced only inside
a function body (not in the call) are picked up for free. parser.ts
gains an optional paramNames argument to parseFormula() producing a new
ParamNode leaf, used only when parsing a function body.

Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with
inline (not toast) validation -- name/parameter shape, no collision with
a built-in or another function, the body parses, and a definition-time
cycle check across the whole call graph. Renaming/deleting a function
mirrors table rename/delete: existing calls are not rewritten, they
become #NAME? on the next rebuild. Fully undo/redo-able and part of the
JSON document (customFunctions: [], migrated for v1 and older v2 docs).

178 tests passing (32 new), vue-tsc and eslint clean. Manually verified
end-to-end in the running app via Playwright: define/call/recalculate,
rename -> #NAME?, a closing cycle rejected inline at save, undo restores
the function library.

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

523 lines
30 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), 178 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 dokumentweit eindeutigem Namen.
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.
**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).
- **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.
### 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 = 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 = [ qualifier ] CELL [ ":" CELL ] ;
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname *)
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: `+ -` < `* /` < unĂ€res `+ -` < `^`.
Der Qualifier ist der **Tabellenname** (nicht der Blattname). `!` und der
Numbers-Stil `::` sind austauschbar. Tabellennamen werden dokumentweit eindeutig
gehalten und **case-/whitespace-tolerant** aufgelöst:
`Kosten!A2` = `'Kosten'!A2` = `kosten!a2` = `Kosten::A2`.
**`$`-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` — 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)`.
**Fehlermodell** (propagiert an abhÀngige Zellen):
`#REF` (ungĂŒltiger/gelöschter Bezug oder unbekannter Tabellenname), `#DIV/0!`,
`#NAME?` (unbekannte Funktion, eingebaut oder benutzerdefiniert), `#ERR`
(Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten
Funktion), `#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 Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure, Evaluator/Graph sehen danach eine ganz normale AST. |
| `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, 178 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.
- **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. 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.
- **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.).
## 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)
```