Table names were document-unique; now they're unique per sheet only -- two different sheets may each hold a table called "Umsatz". A plain, unqualified reference (Umsatz!B2 / Umsatz::B2, unchanged from before) resolves to the table on the SAME sheet as the formula referencing it when the name is otherwise ambiguous, falling back to the single document-wide match when it isn't ambiguous at all -- exactly as before for every existing formula. The new explicit 3-part form (Analyse!Umsatz::B2, quoted where a name has spaces) always targets the named sheet's table regardless of where the formula itself lives, for the case that's still genuinely ambiguous. Engine: parser.ts's afterFirstQualifierToken() disambiguates via lookahead -- "X!Y::" (a name, then "::") is the new 3-part sheet+table form; a bare "X!Y" keeps its original single-qualifier meaning, so every formula written before this exists still parses identically. CellNode/RangeNode gain an optional sheetQualifier field. Workbook.resolveTableName(name, sheetName?) implements the resolution policy; TableModel gains sheetName. precedentsOf() now also sets homeTableId (previously only evaluateCell did), so graph edges use the same same-sheet-preference resolution an evaluation would. Store: table name uniqueness checks (addTable, renameObject) are now scoped to the target sheet instead of the whole document. Point-mode's qualifyRef() inserts the full "Blatt!Tabelle::" form only when the clicked table's name is genuinely ambiguous and lives on a different sheet than the editor; otherwise the simple form, unchanged. 200 tests passing (19 new), vue-tsc and eslint clean, verified live in the running app: renamed a Blatt-2 table to the same name as Analyse's seed "Umsatz", confirmed an unqualified reference resolves per-sheet, an explicit sheet-qualified one always targets the named sheet, and the original sheet's own formulas are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
583 lines
34 KiB
Markdown
583 lines
34 KiB
Markdown
# 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), 200 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.
|
||
|
||
### 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 = [ 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: `+ -` < `* /` < 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` — 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, 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), `#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, 200 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; 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.
|
||
- **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.).
|
||
|
||
## 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)
|
||
```
|