Files
numolus/README.md
T
2026-08-29 17:47:12 +02:00

345 lines
18 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), 102 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
(Formelleiste) ist stets die zuletzt angeklickte.
### 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`.
**Funktionen** (case-insensitiv, Trenner `;`): `SUM`, `AVG` (Alias `AVERAGE`),
`MIN`, `MAX`, `COUNT`. 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), `#ERR` (Parse-Fehler), `#CIRC` (Zirkelbezug,
auch tabellenübergreifend), `#VALUE!` (Typfehler in Arithmetik — *dokumentierte
Erweiterung*). Referenz auf leere Zelle = `0` in Arithmetik, von `COUNT` nicht
gezählt.
---
## 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. |
| `workbook.ts` | Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert `EngineHost`. |
**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, 102 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
Bereiche, `;`-Argumente, Parse-Fehler.
- **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.
- **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, kein
Kopieren/Einfügen von Zellbereichen, keine Undo-Historie, kein Snapping der
Canvas-Objekte.
## 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, …
composables/ # Theme, Toasts
data/seed.ts # vorbefülltes Dokument
styles/main.css # Design-Tokens (Light/Dark)
```