•14 min read

Zeilenexakte Text-Geometrie & 2D-SDF-Gitter mit TreeWalker im Hero-Layout

Die Kombination aus zeilenexakter DOM-Textvermessung mittels document.createTreeWalker und Range.getClientRects() sowie kontinuierlichen zweidimensionalen Signed Distance Fields (2D SDF) löst ein klassisches Dilemma moderner Web-Oberflächen: Sie ersetzt starre, rechteckige CSS-Block-Bounding-Boxes durch die reale, treppenförmige Zeilenkontur des umbrochenen Textes. Auf dieser Basis platziert ein deterministischer 2D-Optimierer im Hero-Bereich unserer Startseite sowohl das interaktive WebGL-Porträt als auch ein physikbasiertes Hintergrund-Punktraster millimetergenau in die frei gewordenen Freiräume – ohne Überlappungen, ohne Layout-Shift (CLS = 0) und mit maximal zehn gebündelten Canvas-2D-Draw-Calls pro Frame.

Die folgende Architektur-Übersicht zeigt den qualitativen und quantitativen Unterschied zwischen klassischer CSS-Block-Geometrie, CSS Shapes und unserer sub-elementaren TreeWalker + 2D-SDF-Pipeline:

DimensionKlassisches CSS Grid / Flexbox (getBoundingClientRect)CSS Shapes (shape-outside)Sub-Element TreeWalker + 2D-SDF-Pipeline (hackenberg.tech)
Geometrische AuflösungGrobe Element-Box (max-w-2xl); ignoriert vorzeitige Zeilenumbrüche und verschenkt rechtsseitigen Freiraum.Fließtext umfließt ein statisches Float-Bild; keine Rückkopplung der Textkontur an Canvas-Hintergründe.Zeilenexakte Range.getClientRects()-Vermessung jedes Textknotens; präzises 1D-Intervall-Merging (bands[]).
Dynamische Objekt-PlatzierungStarre Breakpoints (md:, lg:); führt auf Zwischenbreiten zu Sprüngen oder konservativ geschrumpften Bildern.Nur für Inline-Floats nutzbar; kein globaler 2D-Kollisions-Solver für unabhängige UI-Layer.Exakter 2D-Solver prüft konkurrierende Kandidaten (Rechts-Mittig vs. Oben neben <h1>) mit +4px Hysterese.
Hintergrund-Partikel & KonturschnittRechteckiger Ausschnitt oder Übermalen von Text (schlechter Kontrast und visuelle Unruhe).Keine Schnittstelle zu HTML5 Canvas oder WebGL-Partikelsystemen.Kontinuierliches 2D-SDF (sdRoundRect) mit polynomieller C1C^1-Verschmelzung (smin) und 32px-smoothstep-Fade.
Rendering-Effizienz (Canvas 2D)Einzelnes ctx.fill() pro Partikel erzeugt hunderte State-Changes pro Frame bei individueller Opazität.Nicht anwendbar (rein deklaratives Text-Wrapping im Browser-Layout).10-Bucket Opacity Quantisierung (alphaBin = 0..9) reduziert hunderte Punkte auf maximal 10 Draw-Calls/Frame.
Webfont- & First-Paint-StabilitätAnfällig für Flash of Unstyled Text (FOUT) und nachträgliche Geometrie-Sprünge beim Font-Swap.Reflow bei Webfont-Ladevorgang verschiebt Zeilenumbrüche sichtbar.Synchronisiertes Zwei-Barrieren-Gate (document.fonts.ready ∧\land portrait:ready) garantiert CLS = 0.
Tipp

Live-Implementierung im Quellcode:
Der vollständige Geometrie- und Platzierungs-Solver ist in src/pages/index.astro (Zeilen 1264–1617) implementiert; das kontinuierliche 2D-SDF-Gitter und die Feder-Dämpfer-Physik befinden sich in src/components/ReactiveDotGrid.astro.

Warum versagen klassische CSS-Block-Boxen bei asymmetrischen Hero-Layouts?

Klassische CSS-Block-Boxen versagen bei asymmetrischen Hero-Layouts, weil das Browser-Box-Modell jedes Block-Element (<h1>, <p>, <div>) als achsenparalleles Rechteck über seine volle Containerbreite (max-width) behandelt, selbst wenn einzelne Textzeilen durch den Zeilenumbruch deutlich früher enden. Dadurch entsteht am rechten Rand von Überschriften und Absätzen ein unsichtbarer „toter Raum” von oft 180 bis 340 Pixeln Breite, der für benachbarte Grafiken oder interaktive Hintergrund-Gitter gesperrt bleibt.

Seit der Formalisierung des optimalen Zeilenumbruchs durch [KP81] und moderner linearer Constraint-Solver für Benutzeroberflächen wie Cassowary [BBS01] gilt im UI-Engineering ein strukturelles Spannungsfeld: Typografie erzeugt im Flattersatz (Ragged Right) eine organische, stufenförmige Außenkontur, während Layout-Container in HTML und CSS streng rechteckige Boxen aufspannen.

Im Hero-Bereich unserer Startseite treffen fünf semantisch unterschiedliche Inhaltsschichten aufeinander:

  1. Hauptüberschrift (#hero-heading): Zweizeiliges <h1> mit großem Schriftgrad (text-4xl md:text-6xl, Font Outfit Extrabold), dessen erste Zeile ("Hi, I am") oder zweite Zeile ("Dr. Georg Hackenberg") je nach Viewport-Breite unterschiedlich weit nach rechts ragt.
  2. Untertitel (#hero-subtitle): Mehrzeilige Rollenbeschreibung mit Bullet-Separatoren.
  3. Biografie-Absatz (#hero-bio): Fließtext mit eingebetteten <strong>-Hervorhebungen und Inline-Emojis.
  4. Entity-Trust-Pills (#hero-trust-pills): Flex-Wrap-Zeilen aus sechs abgerundeten Glassmorphism-Badges (FH Upper Austria, TU Munich, RWTH Aachen, Uni Mannheim, CADdrive, Mentawise).
  5. Aktionsleiste (#hero-actions-row): Primärer Call-to-Action-Button (#hero-contact-btn) neben vier quadratischen Social-Media-Schaltflächen (#hero-social-group).

Verwendet man zur Kollisionsvermeidung die Standardmethode Element.getBoundingClientRect() auf den Block-Elementen <h1> und <p>, meldet der Browser für jeden Absatz konstant die volle CSS-Maximalbreite (max-w-2xl, also 672 px672\text{ px}). Auf einem typischen Tablet- oder kompakten Laptop-Viewport (768 px768\text{ px} bis 1150 px1150\text{ px}) bleibt rechts neben dieser 672 px672\text{ px} breiten Block-Box kaum Platz – obwohl in Wahrheit neben der kürzeren <h1>-Überschrift oder neben den umbrochenen Pill-Reihen hunderte Quadratpixel völlig frei wären.

BLOCK-BOX VS. TREEWALKER-ZEILENKONTUR 1. CSS Block-Box: Element.getBoundingClientRect() Zeile 1: Hi, I am Dr. Georg Hackenberg Zeile 2: Professor @ FH OÖ Wels Zeile 3: AI & Systems Toter Block-Raum 180–340 px verschenkt (Blockiert Porträt & Grid) 2. Sub-Element Kontur: TreeWalker + Range.getClientRects() Band 1: [top: 0, bottom: 28, right: 352] Band 2: [top: 38, bottom: 66, right: 288] Band 3: [right: 196] Freier 2D-Raum Exakte Treppenkontur bands[] für Porträt & SDF
Vergleich zwischen starrer CSS Block-Bounding-Box und zeilenexakter TreeWalker-Kontur

Um diesen verschenkten Raum zurückzugewinnen, müssen wir die Ebene der CSS-Block-Container verlassen und die tatsächliche optische Belegung jeder einzelnen Textzeile und jedes Inline-Badges vermessen.

Wie extrahiert man zeilenexakte Text-Konturen mit TreeWalker und Range.getClientRects()?

Die zeilenexakte Extraktion einer Text-Kontur gelingt durch die Kombination des DOM-Iterators document.createTreeWalker(el, NodeFilter.SHOW_TEXT) mit der CSSOM-View-Methode Range.getClientRects(). Während der TreeWalker sämtliche Textknoten auch über verschachtelte Inline-Elemente (<span>, <strong>) hinweg traversiert, liefert ein darüber gelegtes Range-Objekt für jeden Zeilenumbruch ein separates, pixelgenaues Client-Rechteck der tatsächlich gerenderten Glyphen.

Würde man lediglich el.getClientRects() auf dem Eltern-Element <p> aufrufen, erhielte man weiterhin nur eine einzige große Block-Box. Erst wenn man auf die Blattknoten des DOM-Baums (Text-Nodes) hinabsteigt und deren Inhalt über document.createRange() markiert, schlüsselt die Layout-Engine des Browsers gemäß W3C CSSOM View Specification [W3C16] jeden mehrzeiligen Textknoten in ein DOMRectList-Array seiner einzelnen Bildschirmzeilen auf.

Schritt 1: Tiefentraversierung aller Textknoten via TreeWalker

Da unsere Überschrift Farbverlaufs-Spans (<span class="heading-gradient-brand">) und der Biografie-Text zahlreiche <strong>-Elemente enthält, reicht eine flache Iteration über childNodes nicht aus. Der im WHATWG DOM Standard [Wha24] spezifizierte TreeWalker mit der Bitmaske NodeFilter.SHOW_TEXT besucht jeden Textknoten in Dokumentreihenfolge ohne Rekursions-Overhead:

const gridRect = heroGrid.getBoundingClientRect();
const rawRects = [];

function collectLineRects(el) {
  if (!el) return;
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
  let node;
  while ((node = walker.nextNode())) {
    if (!node.textContent || !node.textContent.trim()) continue;
    const r = document.createRange();
    r.selectNodeContents(node);
    const clientRects = r.getClientRects();
    for (let i = 0; i < clientRects.length; i++) {
      const cr = clientRects[i];
      if (cr.width < 2 || cr.height < 2) continue;
      rawRects.push({
        top: cr.top - gridRect.top,
        bottom: cr.bottom - gridRect.top,
        right: cr.right - gridRect.left
      });
    }
  }
}

Für die Trust-Pills (#hero-trust-pills) und die Aktionsleiste (#hero-actions-row) ergänzen wir rawRects um die exakten Außenkanten jedes sichtbaren Kind-Buttons (child.getBoundingClientRect()). Dadurch sind sämtliche interaktiven Badges und Textfragmente in einem einheitlichen lokalen Koordinatensystem relativ zu #hero-grid erfasst.

Schritt 2: 1D-Intervall-Merging zu horizontalen Konturbändern (bands[])

Weil eine einzelne visuelle Bildschirmzeile aus mehreren DOM-Textknoten bestehen kann (beispielsweise normaler Text gefolgt von einem <strong>-Knoten in derselben Zeile), liefert rawRects zunächst mehrere Teilrechtecke auf gleicher vertikaler Höhe. Wir sortieren rawRects aufsteigend nach top und verschmelzen vertikal überlappende Rechtecke zu disjunkten horizontalen Konturbändern bands[]:

rawRects.sort(function (a, b) { return a.top - b.top; });

const bands = [];
for (let i = 0; i < rawRects.length; i++) {
  const r = rawRects[i];
  if (bands.length === 0) {
    bands.push({ top: r.top, bottom: r.bottom, right: r.right });
    continue;
  }
  const last = bands[bands.length - 1];
  const verticalOverlap = Math.min(last.bottom, r.bottom) - Math.max(last.top, r.top);
  const minHeight = Math.min(last.bottom - last.top, r.bottom - r.top);
  const isSameLine = Math.abs(last.top - r.top) < 10 || verticalOverlap > minHeight * 0.35;

  if (isSameLine) {
    last.top = Math.min(last.top, r.top);
    last.bottom = Math.max(last.bottom, r.bottom);
    last.right = Math.max(last.right, r.right);
  } else {
    bands.push({ top: r.top, bottom: r.bottom, right: r.right });
  }
}

Zwei Fragmente gelten genau dann als dieselbe visuelle Zeile (isSameLine), wenn ihre Oberkanten um weniger als 10 px10\text{ px} voneinander abweichen oder ihre vertikale Schnittmenge mehr als 35 %35\,\% der kleineren Fragmenthöhe beträgt (verticalOverlap > minHeight * 0.35). Dieses Kriterium fängt auch Inline-Emojis (🏙️, 🏰, 🌄) und fetten Text mit minimal abweichender Baseline-Box zuverlässig in einem gemeinsamen Band Bi=[topi,bottomi,righti]B_i = [\text{top}_i, \text{bottom}_i, \text{right}_i] ein.

Wie berechnet der 2D-Optimierer die kollisionsfreie Porträt-Platzierung?

Der 2D-Platzierungs-Optimierer ermittelt auf Basis der extrahierten Konturbänder bands[] in Echtzeit die größtmögliche, garantiert überlappungsfreie Quadratfläche für das WebGL-Porträt (DepthPortrait.astro), indem er zwei geometrische Kandidatenpositionen (Rechts-Mittig vs. Oben neben <h1>) gegeneinander auswertet. Reicht auf schmalen Smartphones der horizontale Freiraum für keinen der beiden Kandidaten bis zur Mindesthöhe der <h1>-Überschrift aus, blendet der Solver das Porträt sauber aus.

2D KOLLISIONSFREIER PORTRÄT-SOLVER #hero-grid (Breite W, Höhe H) — maxRightInVerticalRange(yTop, yBottom) H1 Zeile 1 (H_h1 = 78px) H1 Zeile 2 (b.right = 260) Subtitle Band (b.right = 295) Bio & Trust-Pills Bänder GAP_X = 18px | GAP_Y = 6px CTA + Social-Icons Band reqLeft (+18px) Kandidat B Oben (H_h1) Kandidat A Rechts-Mittig (S_center) yTop = (H - S) / 2 yBottom = (H + S) / 2 reqLeft + S <= W Entscheidungsregel & 4px-Hysterese: S_center + 4 >= S_top Falls max(S_center, S_top) < H_h1 → Porträt kollisionsfrei ausblenden
2D Kollisionsfreier Porträt-Platzierungs-Solver mit Kandidat A Rechts-Mittig und Kandidat B Oben neben H1

Die Hindernisfunktion maxRightInVerticalRange(yTop, yBottom)

Gegeben sei ein vertikales Zielintervall [ytop,ybottom][y_{\text{top}}, y_{\text{bottom}}] für das Porträtquadrat der Kantenlänge SS. Um jede optische Berührung mit den Textzeilen auszuschließen, definieren wir einen Sicherheitsabstand von PORTRAIT_GAP_X = 18 Pixeln horizontal und PORTRAIT_GAP_Y = 6 Pixeln vertikal. Die Funktion maxRightInVerticalRange bestimmt die am weitesten nach rechts ragende Textkante innerhalb dieses vertikalen Korridors:

maxRight(ytop,ybottom)=max⁡{b.right  ∣  b∈bands  ∧  b.bottom>ytop−6  ∧  b.top<ybottom+6}\text{maxRight}(y_{\text{top}}, y_{\text{bottom}}) = \max \Big\{ b.\text{right} \;\Big|\; b \in \text{bands} \;\land\; b.\text{bottom} > y_{\text{top}} - 6 \;\land\; b.\text{top} < y_{\text{bottom}} + 6 \Big\}

Daraus folgt unmittelbar die minimal zulässige linke Startkoordinate reqLeft des Porträts im Intervall [ytop,ybottom][y_{\text{top}}, y_{\text{bottom}}]:

reqLeft(ytop,ybottom)=maxRight(ytop,ybottom)+18 px\text{reqLeft}(y_{\text{top}}, y_{\text{bottom}}) = \text{maxRight}(y_{\text{top}}, y_{\text{bottom}}) + 18\text{ px}

Wettbewerb der zwei Platzierungs-Kandidaten

Der Solver in src/pages/index.astro prüft nun zwei architektonisch sinnvolle Ankerpositionen innerhalb der Hero-Breite WW (gridWidth) und Hero-Höhe HH (gridHeight):

  1. Kandidat A – „Rechts-Mittig” (sizeRightCenter): Auf Desktop- und breiten Tablet-Viewports soll das Porträt vertikal zentriert rechts neben dem gesamten Textblock schweben. Da eine größere Kantenlänge SS das vertikale Intervall [(H−S)/2,(H+S)/2][(H - S)/2, (H + S)/2] aufweitet und dadurch potenziell längere Textzeilen in der Mitte einschließt, sucht der Solver absteigend ab Smax⁡=min⁡(480,H)S_{\max} = \min(480, H) das größte Quadrat SS, das kollisionsfrei in die verbleibende Breite passt:
    let sizeRightCenter = 0;
    let reqLeftRightCenter = 0;
    const maxPossibleRightCenter = Math.min(480, gridHeight);
    for (let S = maxPossibleRightCenter; S >= 1; S--) {
      const yTop = (gridHeight - S) * 0.5;
      const yBottom = (gridHeight + S) * 0.5;
      const reqLeft = maxRightInVerticalRange(yTop, yBottom) + PORTRAIT_GAP_X;
      if (reqLeft + S <= gridWidth) {
        sizeRightCenter = S;
        reqLeftRightCenter = reqLeft;
        break;
      }
    }
  2. Kandidat B – „Oben neben <h1>” (sizeTopRight): Auf mittleren Viewports, auf denen der Fließtext bereits fast die gesamte Breite einnimmt, die kurze <h1>-Überschrift (max-w-[21rem]) rechts oben jedoch noch eine Nische frei lässt, kann das Porträt bündig auf Höhe der <h1> ([0, h1Height]) sitzen. Seine Kantenlänge ist exakt auf die Höhe der Überschrift h1Height (Hh1H_{\text{h1}}) normiert, sofern gridWidth - reqLeft >= h1Height erfüllt ist.

Auswahlregel, Hysterese und automatische Zentrierung im Restraum

Damit das Porträt niemals kleiner als die Hauptüberschrift wirkt, gilt die harte Untergrenze Smin⁡=Hh1S_{\min} = H_{\text{h1}}. Zwischen beiden Kandidaten entscheidet der Solver zugunsten der zentrierten Hauptposition, sobald diese mindestens gleich groß oder innerhalb einer 4 px4\text{ px}-Rundungstoleranz liegt (prefersCenter = sizeRightCenter + 4 >= sizeTopRight).

Sobald Position und Größe S∗S^* feststehen, wird das Porträt im verbleibenden freien Korridor zwischen reqLeft und WW (gridWidth) exakt mittig ausgerichtet:

xleft=round ⁣(reqLeft+W−reqLeft−S∗2)x_{\text{left}} = \text{round}\!\left(\text{reqLeft} + \frac{W - \text{reqLeft} - S^*}{2}\right)

Ergänzend passt die vorangestellte Routine in src/pages/index.astro den primären Kontakt-Button (#hero-contact-btn) dreistufig an den verfügbaren Platz an („Get in Touch” →\rightarrow „Contact” →\rightarrow Envelope-Icon), sodass die Aktionszeile auf keinem Viewport jemals ungewollt umbricht.

Wie formt ein 2D Signed Distance Field (SDF) das reaktive Punktraster um die Textkontur?

Ein zweidimensionales Signed Distance Field (2D SDF) ordnet jedem Gitterpunkt (x0,y0)(x_0, y_0) des Hintergrund-Canvas seinen kürzesten vorzeichenbehafteten euklidischen Abstand zur nächstgelegenen Textzeile und zum äußeren Hero-Rahmen zu. Durch die Verknüpfung abgerundeter Box-Distanzfunktionen (sdRoundRect) über Inigo Quilez’ polynomielles Smooth Minimum (smin) entsteht eine organisch gerundete L-förmige Ausschnittskontur mit weichem 32-Pixel-Opazitätsübergang.

Architektur-Skizze der TreeWalker-Konturbänder und des 2D-SDF-Gitters am Whiteboard im Almtal Home Office

Nachdem syncHeroResponsiveLayout() die Konturbänder bands[] berechnet hat, serialisiert das Skript die ganzzahligen Tripel [Math.round(b.top), Math.round(b.bottom), Math.ceil(b.right)] in das Attribut data-contour der Komponente ReactiveDotGrid.astro und feuert das Event dotgrid:layout.

1. Exakte 2D-Distanzfunktion eines abgerundeten Rechtecks (sdRoundRect)

Klassische SDF-Verfahren wurden in der Echtzeit-Computergrafik durch [Gre07] für randscharfe Vektortexturen etabliert und von [Qui18] für analytische 2D-Primitiven verallgemeinert. Für einen Gitterpunkt p=(px,py)\mathbf{p} = (p_x, p_y) und ein achsenparalleles Rechteck [xL,xR]×[yT,yB][x_L, x_R] \times [y_T, y_B] mit Mittelpunkt c\mathbf{c}, Halbausdehnung h=(hw,hh)\mathbf{h} = (h_w, h_h) und Eckenradius reff=min⁡(r,hw,hh)r_{\text{eff}} = \min(r, h_w, h_h) lautet die exakte vorzeichenbehaftete Distanz:

q=∣p−c∣−(h−reff)\mathbf{q} = |\mathbf{p} - \mathbf{c}| - (\mathbf{h} - r_{\text{eff}})

droundRect(p)=∥max⁡(q,0)∥2+min⁡(max⁡(qx,qy),0)−reffd_{\text{roundRect}}(\mathbf{p}) = \|\max(\mathbf{q}, \mathbf{0})\|_2 + \min(\max(q_x, q_y), 0) - r_{\text{eff}}

Dabei gilt:

  • d<0d < 0: Der Punkt liegt innerhalb des Rechtecks.
  • d=0d = 0: Der Punkt liegt exakt auf der abgerundeten Konturlinie.
  • d>0d > 0: Der Punkt liegt außerhalb des Rechtecks, und dd entspricht dem exakten euklidischen Pixelabstand zur nächstgelegenen Kante oder Ecke.

2. Organische C1C^1-Stufenverschmelzung mit Quilez’ Polynomial smin

Würde man die Distanz zum gesamten Textblock schlicht als hartes Minimum min⁡idi(p)\min_i d_i(\mathbf{p}) über alle Zeilenbänder ii berechnen, entstünden an jedem Zeilenumbruch-Rücksprung scharfe 90∘90^\circ-Knicke im Gradientenfeld. Um diese Treppenstufen wie eine flüssige Membran abzurunden, setzen wir das polynomielle Smooth Minimum (smin) nach [Qui13] ein:

h=max⁡(k−∣a−b∣,0)k,smin(a,b,k)=min⁡(a,b)−14h2kh = \frac{\max(k - |a - b|, 0)}{k}, \qquad \text{smin}(a, b, k) = \min(a, b) - \frac{1}{4} h^2 k

In ReactiveDotGrid.astro bestimmen wir für jeden Punkt des 22 px22\text{ px}-Gitters die zwei kleinsten Distanzen d1≤d2d_1 \le d_2 zu allen um CLEARANCE_X = 16 Pixel und CLEARANCE_Y = 14 Pixel aufgeweiteten Zeilenbändern und verschmelzen sie mit Radius k=12k = 12:

let d1 = Infinity;
let d2 = Infinity;
for (let i = 0; i < bands.length; i++) {
  const b = bands[i];
  const d = sdRoundRect(
    x0,
    y0,
    -500,
    b[0] - CLEARANCE_Y,
    b[2] + CLEARANCE_X,
    b[1] + CLEARANCE_Y,
    12
  );
  if (d < d1) {
    d2 = d1;
    d1 = d;
  } else if (d < d2) {
    d2 = d;
  }
}
const dText = d2 < Infinity ? smin(d1, d2, 12) : d1;

// Hard Culling innerhalb der Text-Sicherheitszone
if (dText <= 0) continue;

// Konkave Ausrundung (Fillet) zwischen Außenrahmen (-dOuter) und Textkontur (dText)
const distInsideOpen = smin(-dOuter, dText, 22);

Ein zweiter Aufruf smin(-dOuter, dText, 22) verrundet zusätzlich die konkaven Innenecken, an denen die Textkolumne oben und unten auf den abgerundeten Hero-Außenrahmen (dOuter) trifft. Anschließend moduliert eine kubische Hermite-Interpolation (smoothstep(0, 32, dText)) die Punkt-Opazität sanft von 00 (direkt an der Sicherheitsgrenze) auf 11 (ab 32 px32\text{ px} Abstand).

2D SDF-DISTANZFELD & CANVAS-BATCHING-PIPELINE 1. 2D Gitter & Abgerundete Box-SDF (sdRoundRect) • Gitterpunkte (spacing = 22px) gegen Außenrahmen dOuter < 0 prüfen • Zeilenbänder mit Sicherheitsabstand CLEARANCE (16×14 px, r = 12) 2. Organische C¹-Verschmelzung (Quilez Polynomial smin) • Treppenstufen glätten: dText = smin(d1, d2, k = 12) | Culling dText <= 0 • Konkaves Rahmen-Fillet: distInsideOpen = smin(-dOuter, dText, k = 22) 3. Hermite Smoothstep Alpha & Feder-Dämpfer-Physik • 32px Ausblendrampe: alpha = smoothstep(0, 32, dText) × cornerAlpha • Maus-Repulsion (R = 115px) & Hooke-ODE (k = 150, c = 18) 4. 10-Bucket Opacity Quantisierung (Canvas 2D Batching) • Sortierung in alphaBin = 0..9 bündelt hunderte Punkte pro Opazitätsstufe • Maximal 10 gebündelte ctx.beginPath() / ctx.fill() Draw-Calls pro Frame
Vierstufige 2D Signed Distance Field und Canvas-Batching-Pipeline in ReactiveDotGrid.astro

Wie erreichen wir 60 FPS durch 10-Bucket Opacity Quantisierung und Feder-Dämpfer-Physik?

Um trotz hunderter individuell abgedunkelter und animierter Gitterpunkte konstant 60 FPS bei minimaler CPU-Last zu garantieren, quantisiert ReactiveDotGrid.astro die kontinuierliche SDF-Randopazität beim Gitteraufbau in exakt zehn diskrete Opazitäts-Buckets (alphaBin = 0..9). Während der Render-Schleife werden sämtliche Ruhelage-Punkte eines Buckets in einem einzigen gemeinsamen ctx.beginPath()- und ctx.fill()-Aufruf gezeichnet, was hunderte einzelne Canvas-State-Wechsel auf maximal zehn gebündelte Draw-Calls pro Frame reduziert.

10-Bucket Opacity Batching im HTML5 2D Canvas

Im unmittelbaren Modus (Immediate Mode) der HTML5 Canvas 2D API ist nicht die Berechnung von Math.sin oder ctx.arc der Flaschenhals, sondern das ständige Umschalten von ctx.fillStyle und das Absenden hunderter einzelner ctx.fill()-Befehle an den Grafiktreiber. Da sich die statische Randabschwächung alphaFactor = textAlpha * cornerAlpha jedes Punktes nur bei einem Resize ändert, diskretisieren wir sie bereits in rebuildLattice() in zehn Stufen und sortieren das Punkt-Array aufsteigend nach alphaBin:

const alphaFactor = textAlpha * cornerAlpha;
if (alphaFactor < 0.04) continue;

const alphaBin = Math.min(9, Math.floor(alphaFactor * 10));
// ...
dots.sort((a, b) => a.alphaBin - b.alphaBin);

In der draw()-Schleife iteriert der Renderer über die zehn Buckets b∈{0,…,9}b \in \{0, \dots, 9\}, setzt ctx.fillStyle ein einziges Mal pro Bucket und fügt alle zugehörigen Kreise mittels ctx.moveTo(x + r, y) und ctx.arc(x, y, r, 0, Math.PI * 2) demselben Subpfad hinzu:

for (let bin = 0; bin < 10; bin++) {
  if (idx >= len || dots[idx].alphaBin > bin) continue;

  const binAlpha = baseAlpha * ((bin + 0.5) / 10);
  ctx.fillStyle = `rgba(${baseRgb}, ${binAlpha.toFixed(3)})`;
  ctx.beginPath();

  while (idx < len && dots[idx].alphaBin === bin) {
    const dot = dots[idx];
    idx++;
    if (dot.energy > 0.005) {
      energizedDots.push(dot);
      continue;
    }
    const x = dot.x0 + dot.dx;
    const y = dot.y0 + dot.dy;
    ctx.moveTo(x + r, y);
    ctx.arc(x, y, r, 0, Math.PI * 2);
  }
  ctx.fill();
}

Lediglich die wenigen Punkte im unmittelbaren Maus-Einflussradius (dot.energy > 0.005), deren Farbe dynamisch zwischen dem ruhigen Slate-Ton und unseren Markenfarben (#3b82f6 Brand Blue / #a855f7 Brand Purple) interpoliert wird, werden in energizedDots gesammelt und separat gezeichnet.

Semi-implizite Feder-Dämpfer-ODE und Zwei-Barrieren-First-Paint-Gate

Bewegt sich der Mauszeiger im Abstand d<Rinf=115 pxd < R_{\text{inf}} = 115\text{ px} über das Gitter, erfahren die Punkte eine radiale Abstoßung mit glattem Sinus-Profil:

Δtarget(d)=Δmax⁡⋅(1−dRinf)2⋅sin⁡ ⁣((1−dRinf)π),Δmax⁡=7.5 px\Delta_{\text{target}}(d) = \Delta_{\max} \cdot \left(1 - \frac{d}{R_{\text{inf}}}\right)^2 \cdot \sin\!\left(\left(1 - \frac{d}{R_{\text{inf}}}\right)\pi\right), \qquad \Delta_{\max} = 7.5\text{ px}

Die Rückkehr in die Ruhelage (x0,y0)(x_0, y_0) folgt einer semi-impliziten Euler-Integration eines gedämpften harmonischen Oszillators mit Federsteifigkeit k=150k = 150 und Dämpfungskoeffizient c=18c = 18:

ax=−k(x−xtarget)−cvx,ay=−k(y−ytarget)−cvya_x = -k (x - x_{\text{target}}) - c v_x, \qquad a_y = -k (y - y_{\text{target}}) - c v_y

Mit einer kritischen Dämpfungsrate von ζ=c2k=182150≈0.735\zeta = \frac{c}{2\sqrt{k}} = \frac{18}{2\sqrt{150}} \approx 0.735 schwingen ausgelenkte Gitterpunkte geschmeidig und ohne nervöses Nachzittern in ihre Gleichgewichtslage zurück.

Damit beim ersten Seitenaufruf weder ein unfertiger Webfont-Umbruch (Flash of Unstyled Text) noch ein verspätetes WebGL-Textur-Laden zu einem sichtbaren Umspringen der Kontur führt, synchronisiert src/pages/index.astro die Freigabe über ein deterministisches Zwei-Barrieren-Gate:

  1. Barriere 1 (isFontsReady): Prüft synchron via document.fonts.check('800 60px Outfit') oder wartet auf das im W3C CSS Font Loading Standard [W3C14] definierte Promise document.fonts.ready (abgesichert durch einen 180 ms180\text{ ms} Fallback-Timer). Erst wenn die finalen Outfit-Glyphenmetriken aktiv sind, vermisst syncHeroResponsiveLayout() die Textzeilen und startet die gestaffelte Radial-Intro-Welle (dotgrid:intro).
  2. Barriere 2 (isPortraitReady): Lauscht auf das Custom Event portrait:ready des WebGL-Porträts (mit 1500 ms1500\text{ ms} Sicherheits-Timeout). Erst wenn isFontsReady && isPortraitReady erfüllt ist, schaltet #hero-portrait-cell von visibility: hidden; opacity: 0 weich auf sichtbar.

Fazit: Fünf Architektur-Prinzipien für geometriebewusste Web-Interfaces

Die Verknüpfung von DOM-Text-Geometrie und analytischen 2D-Distanzfeldern zeigt, dass moderne Web-Frontends nicht an die starren Grenzen rechteckiger CSS-Block-Container gebunden sind:

  1. Sub-Element-Vermessung statt Block-Box-Verschnitt: document.createTreeWalker(el, NodeFilter.SHOW_TEXT) in Kombination mit Range.getClientRects() erschließt die wahre Zeilenkontur von Fließtext und Überschriften.
  2. 1D-Intervall-Merging als robuste Geometrie-Brücke: Das Zusammenführen einzelner Textknoten-Rechtecke über einen 35 %35\,\%-Überlappungs-Schwellenwert reduziert hunderte DOM-Fragmente auf wenige kompakte Konturbänder bands[].
  3. Deterministische 2D-Constraint-Optimierung statt starrer Breakpoints: Ein expliziter Kandidaten-Solver (Rechts-Mittig vs. Oben neben <h1>) nutzt jeden freien Pixel über alle Viewport-Breiten hinweg kollisionsfrei aus.
  4. Analytische 2D-SDFs mit smin-Filleting: Die Kombination aus sdRoundRect und Inigo Quilez’ polynomieller smin-Funktion formt harte Treppenstufen in organisch fließende Freiräume um.
  5. 10-Bucket Opacity Batching & Font-Ready-Gates: Die Quantisierung kontinuierlicher SDF-Opazitäten auf zehn Canvas-Draw-Calls pro Frame sowie das document.fonts.ready-Synchronisations-Gate verbinden mathematische Präzision mit 60-FPS-Effizienz und CLS = 0.

Referenzen