18 KiB
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:
- Handwerk — eine von Grund auf selbst geschriebene, schlanke Formel-Engine
(Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug-
Erkennung). Dokumentiert in
src/engine/README.md. - 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.
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: stickygegen 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 verlorenenmouseupselbst. - 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), 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)
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/ — 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 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.
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 vonUmsatz!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,
#REFnach 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;
setTableViewklemmt fixierte Zeilen/Spalten an die Gittergröße; Verkleinern löscht Zellen außerhalb und klemmt nach; Ansichts-Einstellungen im JSON-Round-Trip. - Zahlenformat-Kaskade —
cellDisplaynutzt 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;selectionFormatmeldet nur gemeinsame Werte; Zeilenhöhe (Standard + je Zeile), Spaltenbreite ab 24 px; Verkleinern prunedformats/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 —
addToSelectionbehält frühere Rechtecke & verschiebt die aktive Zelle, erneutes Klicken schaltet eine 1×1-Auswahl ab,extendActiveRangewä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)