Technische Spezifikation der animierten Buchstaben-Tafel aus Vision §3.
Ergänzt architektur.md §11 (und §5 für den
Width-Profile-Resolver).
Kernprinzip: Die Animation ist direkter Effekt des Duktus-Priors
(architektur.md §2). Sie zeigt nicht nur das
fertige Bild, sondern auch wie es entsteht — Schreibreihenfolge,
Ansatzpunkte, Schwellzug-Aufbau live.
Wichtig — verworfener Ansatz: Standard-SVG-Animations-Bibliotheken
(Vivus.js, GSAP DrawSVG-Plugin, stroke-dashoffset-Tricks auf statischen
Pfaden) animieren einen fixbreiten Pfad. Sie passen nicht zum
generativen Schwellzug-Modell mit variabler Strichbreite über Zeit. Wir
brauchen einen eigenen Renderer.
Scope (Gate 4 in architektur.md §8):
Ein Glyph spielt mit korrekter Schreibreihenfolge ab. Implementiert als
app/src/components/WrittenGlyph/WrittenGlyph.tsx (Quiz-Prompt und die
„Fertig geschrieben"-Stufe im Admin-Diagnose-Dialog; der Landing-Hero
schreibt Stand heute noch den GLKurrent-Font per Clip-Path und wechselt
erst nach der Wort-Komposition aus Templates auf diesen Renderer). Der
gelieferte Stand geht über das Gate-4-Minimum (konstante Breite auf der
Centerline) hinaus: enthüllt wird die gefüllte Schwellzug-Silhouette.
WrittenGlyphbezieht das Render-Payload über den geteilten Render-Cache (app/src/lib/api/renderCache.ts), der pro Wort/Tafel gebündeltGET /sources/{source_id}/write/glyphs?keys=…abruft (/diagnosticbleibt dem Admin-Dialog vorbehalten). Das Payload liefert pro Pen-Stroke die gefüllte Schwellzug-Silhouette — bevorzugtoutline_paths(Kapsel-Union als Ringlisten: Außenkontur + Löcher, gerendert als ein Pfad mitfill-rule: evenodd, damit Schleifenaugen offen bleiben; Fallback: die älterenoutline_polygons) — und die zugehörigecenterlines_template(das geordnete Rückgrat jedes Polygons in Schreibreihenfolge, Template-Raum;core/pipeline.py). Nichtskeleton_polyline_pxverwenden — das ist eine Pixel-Wolke ausnp.where(skel)in Row-Major-Reihenfolge, also unsortiert entlang des Strichs.- Frontend füllt die Polygone und maskiert sie mit einem breit gestrichelten Pfad, der entlang der jeweiligen Centerline gesweept wird.
- Der Masken-Pfad nutzt
pathLength={1}+stroke-dasharray: 1; ein animierterstroke-dashoffsetvon1auf0lässt die Tinte entlang des realen Schreibwegs erscheinen. - Animation per WAAPI (
el.animate(...)inapp/src/hooks/useStrokeReveal.ts), bewusst nicht als globale Emotion-@keyframes— Live-Tippen auf/federprobewürde das Stylesheet unbegrenzt wachsen lassen; die Animationen sind an die Elemente gebunden und werden beim Cleanup gecancelt. Das Timing kommt ausapp/src/lib/strokeTiming.tsund ist nicht konstant: das Zwei-Drittel-Gesetz der Schreibkinematik (v ∝ κ^(−1/3), die Feder verlangsamt in Kurven) liefert pro Teilstrich nichtlineare Dashoffset-Keyframes, und die Isochronie (Dauer ∝ Länge^0.6) verteilt die Gesamtdauer sublinear über die Striche; vor einem Absetz-Strich liegt eine kurze Stiftpause (PEN_PAUSE_MS). Nach dem Schreibende folgt der „Eisengallus-Settle“ (Tintenfarbe frisch → oxidiert). Fallback ohne WAAPI oder bei degeneriertem Profil: die fertige Silhouette steht sofort (wie beim Reduced-Motion-Pfad). - Mehrstrich-Duktus (Absetzen): jeder Pen-Stroke ist ein eigenes
Polygon mit eigener Centerline (
trace_meta.stroke_starts, gespeist aus denpen_up-Markern imraw_path). Die Striche spielen nacheinander, eine Stiftabhebung bleibt eine echte Lücke (kein Pfad über die Lücke) — so entsteht z.B. das u als erster Abstrich → zweiter Abstrich, nicht in einem Zug.
// app/src/hooks/useStrokeReveal.ts (Auszug)
// Maske: gestrichelter Pfad mit pathLength=1 — Offset 1 versteckt, 0 zeichnet.
// t.arcAtTime aus lib/strokeTiming: Bogenlängen-Anteil ŝ pro Zeitanteil t̂
// (Zwei-Drittel-Gesetz) → nichtlineare Keyframes statt konstanter Sweep.
const steps = t.arcAtTime.length - 1;
const frames = t.arcAtTime.map((s, k) => ({
strokeDashoffset: `${(1 - s).toFixed(4)}`,
offset: k / steps,
}));
el.animate(frames, { duration: t.dur, delay: t.delay, fill: 'forwards', easing: 'linear' });- Replay-Button startet den Schreibvorgang neu.
prefers-reduced-motionwird respektiert: ohne Animation steht die fertige Silhouette sofort da.- Die volle Control-Leiste (Play/Pause/Reverse, Speed-Slider, Loop-Toggle)
bleibt der animierten Tafel (
/animation, P1) vorbehalten.
- Kein zeitvariabler Schwellzug-Aufbau. Die Silhouette trägt zwar das volle Schwellzug-Profil (variable Breite), wird aber als fertige Form enthüllt — der generative Strichaufbau, bei dem die Breite mit dem Druck über die Zeit entsteht, kommt im post-MVP-Renderer (§2).
- Keine Ligatur-Animation. Ligaturen (
ch,ck,ſt,tz,qu,ß) kommen mit der Erweiterung des Alphabets.
Scope: Schwellzug-Animation für beliebige Glyphen mit beliebigen Hand- Stilen.
Das Duktus-Template ist ein generatives Kalligraphie-Modell — Centerline + zeitvariables Width-Profile. Klassisches Pfad-Rendering passt nicht; korrekte Vorgehensweise:
Pro Frame t:
- Centerline bis Parameter
tabtasten (Bezier oder Catmull-Rom durchanchors). - An jedem Abtastpunkt: lokale Tangente + halbe Strichbreite aus
half_widthsinterpolieren (Width-Profile-Resolver, siehe §3 dieses Docs). - Links + rechts der Centerline je eine Offset-Kurve berechnen (Senkrechte zur Tangente, Distanz = halbe Strichbreite).
- Polygon aus Links-Kurve + reversed(Rechts-Kurve) zusammenbauen (geschlossener Pfad).
- Als Canvas-Polygon füllen oder als SVG-
<path>fill="black"rendern.
| Option | Vor | Contra |
|---|---|---|
| Eigener Canvas-2D-Stroker | Klein (~5 KB), volle Kontrolle, ~60 fps, Offscreen-Canvas möglich | Eigene Stroking-Mathematik (Offset-Kurven, Mitre/Round-Joins, Tangentenbehandlung an Übergängen) |
| CanvasKit (Skia-WASM) | SkPaint::getFillPath() wandelt gestrickten Pfad in gefüllten; produktionsreife Anti-Aliasing-Qualität |
~2–6 MB WASM-Payload; Build-/Deploy-Komplexität |
| Variable Fonts | Hardware-beschleunigt, Standard-CSS | Kein Duktus/Schreibreihenfolge; Animation von font-variation-settings zwingt Rasterizer in jedem Frame → Frame-Drops |
Default-Wahl: eigener Canvas-2D-Stroker im Frontend. CanvasKit als optionales Feature-Flag, wenn maximale Treue gewünscht ist.
- WAAPI (Web Animations API) für Timeline-Steuerung.
- SMIL wird abgeraten (Chromium-Deprecation-Intent, MDN-Empfehlung, Spec-Empfehlung WAAPI/CSS).
- CSS-Animationen reichen nur für triviale Fälle.
class GlyphAnimation {
controller = new AbortController();
playSequence(strokes: StrokeData[], speed = 1.0): Promise<void> {
// Pro Stroke: erst Centerline-Animation, dann nächster Stroke.
// AbortController erlaubt Pause/Restart.
}
}- 60 fps bei einem animierten Glyph.
- 30 fps bei einem Wort von 5–7 Glyphen, gemeinsam animiert (z.B. für „Wort entsteht in echter Hand"-Demo).
- Offscreen-Canvas + Web Worker, wenn nötig.
Das gleiche Library-Schema (architektur.md
§3) trägt zwei Render-Modi. Die Stil-Eigenschaft styles.width_resolver
entscheidet (§5):
| Schriftfamilie | Width-Profile-Resolver | Begründung |
|---|---|---|
| Kurrent (vor 1900) | Druckabhängiger Schwellzug — half_widths wird voll genutzt. |
Spitzfeder; variable Strichbreite ist Wesensmerkmal. |
| Sütterlin (ab 1911) | Konstant — half_widths wird auf den Mittelwert pro Source projiziert. |
Redisfeder; konstante Strichbreite ist Designziel. |
| Andere | Erweiterbar (Federtyp-spezifisch). | Skandinavien / Offenbacher / Volksschrift haben jeweils eigene Federn. |
# Sinngemäß — die reale Auflösung lebt in core/widths.py::resolve_half_widths,
# gesteuert von styles.width_resolver (englische Werte, architektur.md §5).
def resolve_widths(template: Template, style: Style) -> list[float]:
if style.width_resolver == "constant":
return [pooled_nib] * len(template.half_widths) # Source-gepoolter Gleichzug
return template.half_widths # "pressure": voller SchwellzugFrontend bekommt die aufgelösten Silhouetten über die öffentlichen
/write-Payloads (Backend führt die Auflösung durch; api/rendering.py
kalibriert die gepoolte Feder pro Source).
Hanzi Writer und AnimCJK animieren chinesische Schriftzeichen. Wir übernehmen ihr UX-Modell:
- Watch-Modus: der Charakter spielt automatisch ab.
- Quiz-Modus: Nutzer zeichnet einen Stroke; das System matched ihn gegen
den Soll-Stroke und gibt Feedback (
onCorrectStroke,onMistake). - Stroke-Outline-Modus: zeigt nur die Centerline als Hinweis, Nutzer füllt den Bauch selbst.
Render-Engine — Einordnung: Hanzi Writer strickt fixbreite Pfade —
das übernehmen wir nicht. AnimCJKs Mask-Trick (vorgerenderte SVG-Outlines,
per Maske enthüllt) ist dagegen genau die bewusst gewählte
MVP-Zwischenstufe in WrittenGlyph (§1). Was wir nicht übernehmen, ist
der Mask-Trick als finale Render-Engine: den zeitvariablen
Schwellzug-Aufbau kann er nicht, dafür kommt der Post-MVP-Stroker (§2).
Für Thumbnails, Open-Graph-Vorschauen und Offline-Export ist ein server-seitiger MP4/WebM-Export sinnvoll:
- Im Backend per Headless-Canvas (z.B.
node-canvasoder Skia-Python) Frames rendern. - Frames zu MP4 zusammenfügen via FFmpeg.
- MP4 ist ~25–50× kleiner als GIF bei gleicher Qualität.
Nicht im MVP-Scope. Primärpfad bleibt Client-Rendering im Browser.
Die Animation kann unterschiedliche Hände abspielen. Sobald mehrere Sources mit gefitteten Templates vorliegen (P3–P4 der Roadmap), zeigt die gleiche Glyphe in jeder Hand ihre eigene Animation:
- Gleiche Centerline-Topologie (norm-gleicher Duktus).
- Unterschiedliche
anchors/half_widths(Hand-spezifisch). - Unterschiedlicher Width-Profile-Resolver (falls verschiedene Schriftfamilien).
→ Side-by-Side-Animation-Vergleich als Hand-Vergleichs-Feature (Vision §6, Hände-vergleichen-Pfad).
- Animate Calligraphy with SVG
- How to Get Handwriting Animation With Irregular SVG Strokes
- How to Animate SVG: CSS, SMIL, WAAPI, and GSAP Compared
- Intent to deprecate: SMIL (Chromium)
- MDN Web Animations API Concepts
- animCJK GitHub
- Make Me a Hanzi
- Hanzi Writer / GitHub
- GSAP DrawSVGPlugin (für Vergleich, nicht verwendet)
- SVG Line Drawing Animation Solutions 2025 — portalzine.de
- CanvasKit — Skia + WebAssembly
- Variable Fonts (MDN)
- Sütterlin (Wikipedia)
- Kurrent (Wikipedia)