Architektur
Photo Visual Constellation
Jede veröffentlichte Fotografie auf einer einzigen Karte, neben den Fotografien, denen sie ähnelt. Das Layout wird offline berechnet, React hält nur, was Besucher auswählen, und Kamera, Animation und Pixel laufen auf einer einzigen Canvas außerhalb von React.
Veröffentlicht am 7. Oktober 2026
Photo Visual Constellation ist die Ansicht „Visuelle Verbindungen“ meines Fotoarchivs: jede veröffentlichte Fotografie auf einer einzigen Karte, nah bei den Fotografien, denen sie ähnelt. Man kann darüber schwenken, von einem Sternenfeld bis zu einzelnen Fotografien hineinzoomen und von einem Foto zu seinen nächsten visuellen Nachbarn gehen. Die zentrale Entscheidung ist eine strikte Arbeitsteilung. Das Layout wird offline berechnet und ändert sich nie, während man hinschaut. React besitzt, was man ausgewählt hat. Alles, was sich in jedem Frame ändert, also Kamera, Animationen und Pixel, liegt außerhalb von React, auf einer einzigen Canvas.
Das Problem
Das Archiv umfasst 381 veröffentlichte Fotografien. Ich wollte eine Ansicht, die eine Frage beantwortet, die weder das Raster noch die Karte beantworten können: Wie sieht meine Fotografie als Ganzes aus, und welche Bilder gehören visuell zusammen?
Daraus werden vier Anforderungen:
- Das ganze Korpus muss auf einen Bildschirm passen, damit Cluster, Lücken und Ausreißer auf einen Blick sichtbar sind.
- Jede Region muss lesbar werden. Hineinzoomen muss anonyme Punkte in echte Fotografien in ihrem echten Seitenverhältnis verwandeln.
- Bewegung muss mit der Hand Schritt halten. Ziehen, Scrollen und Pinchen dürfen auf nichts warten.
- Die Karte muss zwischen zwei Besuchen gleich bleiben. Wenn sich die Anordnung bei jedem neuen Foto verschöbe, könnte sie niemand lernen.
Nur die letzte Anforderung betrifft Daten. Die ersten drei betreffen das Rendering: Hunderte Bilder, die fortlaufend in Größe, Form und Deckkraft wechseln und neu positioniert werden, so schnell, wie sich ein Zeiger bewegt. Die Architektur ist deshalb vor allem die Antwort auf eine Frage: Was muss pro Frame passieren und was nicht?
Was ich gebaut habe
Die Ansicht ist eine Linse der Seite Fotografie, neben Raster und Karte. Wenn sie sich öffnet:
- Zuerst ist jede Fotografie ein kleiner Stern in ihrer dominanten Farbe, über einem weichen Farbfeld, das die dichten Regionen markiert.
- Ziehen verschiebt die Ansicht; Mausrad, Pinch oder die Zoom-Buttons zoomen; ein Reset-Button rahmt wieder alles.
- Beim Hineinzoomen werden Sterne in der Nähe des Aufmerksamkeitszentrums zu runden Vorschaubildern und weiter innen zu Fotografien in ihrer echten Form.
- Hover hebt eine Fotografie hervor und lässt nach einer kurzen Pause ihre nächsten visuellen Nachbarn aufleuchten.
- Ein Klick wählt sie aus. Linien verbinden sie mit ihren acht nächsten Nachbarn, der Rest der Karte tritt zurück, und ein Seitenpanel zeigt das Foto mit Aktionen: öffnen, eine „visuelle Reise“ durch benachbarte Bilder beginnen oder sehen, wo es entstanden ist.
- Ein zweiter Klick auf die ausgewählte Fotografie öffnet sie in der Vollbildansicht.
Die Nachbarn kommen aus einer Bildähnlichkeitssuche, derselben, die an anderer Stelle der Seite „ähnliche Fotos“ liefert. Die Karte selbst hat keine Kanten. Die Position ist die Beziehung, und die Seite sagt das auch Screenreader-Nutzern: Die Position ist veranschaulichend, keine Messung.
Die Architektur im Überblick
- Offline-LayoutUMAP-2D über Bild-Embeddings · am vorherigen Lauf ausgerichtet
- Gespeicherte PunktephotoId, x, y · 381 Punkte · 23 KB
- React-OberflächeLayout beim Öffnen geladen · Auswahl, Nachbarn, Panel
- Abgeleitete Geometrie (Ref)einmal pro Layout · Abstände, Dichte, Territorien
- Kamera (Ref)Offset + Maßstab · Zeiger, Mausrad, Pinch, Buttons
- Eine Frame-SchleiferequestAnimationFrame · stoppt, wenn sich nichts bewegt
- Eine Canvas2D-FlächeSterne → Kreise → Fotografien
- Das Offline-Layout reduziert das Bild-Embedding jeder Fotografie auf zwei Koordinaten. Es läuft auf Anforderung im Admin-Bereich, nie während eines Seitenaufrufs.
- Die gespeicherten Punkte sind das Einzige, was der Browser über das Layout erhält: drei Zahlen pro Fotografie.
- Die React-Oberfläche lädt diese Punkte beim Öffnen der Linse, verknüpft sie mit den Foto-Metadaten, die die Seite bereits hat, und besitzt alles, was Besucher wählen: die Auswahl, ihre Nachbarn, das Seitenpanel und den Text für assistive Technologien.
- Die abgeleitete Geometrie wird einmal pro Layout berechnet: Punktabstände, Dichte und Farbterritorien.
- Die Kamera ist ein einfaches Objekt in einer Ref, das Eingaben direkt verändern.
- Eine einzige Frame-Schleife schreitet fort, was gerade animiert, zeichnet neu und hört dann auf.
- Die Canvas zeichnet jede Fotografie in der Darstellung, die der aktuelle Zoom verlangt.
Woher die Positionen kommen
Die Positionen werden offline berechnet:
- Ein Layout-Lauf nimmt das Bild-Embedding jeder veröffentlichten Fotografie (1.536 Dimensionen) und projiziert es mit UMAP in 2D. Der Lauf ist geseedet, gleiche Eingaben ergeben also immer dieselbe Karte.
- Danach wird das neue Ergebnis gedreht, skaliert und bei Bedarf gespiegelt, bis es bestmöglich zum vorherigen Layout passt, und erst dann gespeichert. Das ist eine geschlossene 2D-Ausrichtung.
- Jeder Lauf ist eine versionierte Zeile mit seinen Parametern, einem Hash der Fotomenge und der Güte der Ausrichtung. Ausgeliefert wird der neueste abgeschlossene Lauf.
Die Ausrichtung existiert für wiederkehrende Besucher. UMAP darf dieselbe Struktur gedreht oder gespiegelt liefern, und ohne Ausrichtung könnte ein Neuaufbau nach einem neuen Upload die ganze Karte umklappen. Beim letzten Produktions-Neuaufbau haben sich bestehende Fotografien um etwa 0,3 % des Layout-Radius bewegt.
Das offline zu halten war nicht in erster Linie eine Performance-Optimierung. Die Projektion braucht die Vektoren, und die verlassen nie den Server. Außerdem wäre eine Projektion pro Anfrage viel teurer als die Auslieferung ihres Ergebnisses: 23 KB Koordinaten für 381 Fotografien.
Auch der Browser normalisiert die Koordinaten nie. UMAP-Einheiten sind beliebig, deshalb ist jede Konstante im Client relativ: Zoomgrenzen sind Vielfache des Maßstabs, der das ganze Korpus einpasst, und Sprite-Größen folgen dem typischen Abstand benachbarter Punkte. Ein doppelt so großes Layout wird identisch dargestellt.
Eine Canvas statt eines Knotens pro Fotografie
Eine Fotografie ist kein Element der Seite. Die Bühne ist eine einzige <canvas>, gezeichnet mit der 2D-API des Browsers, plus ein paar dekorative Ebenen, ein nativer Button um die Canvas und eine Live-Region für Ansagen. Diese Zahl ist in jeder Zoomstufe und mit jeder Auswahl gleich. Mehr Fotografien bedeuten mehr Pixel, nicht mehr Elemente.
Drei Gründe haben mich dorthin geführt:
- Die Darstellung ändert sich fortlaufend. Eine Fotografie ist ein Stern, dann ein wachsender Kreis, dann ein abgerundeter Rahmen, der sich in ihr echtes Seitenverhältnis verwandelt, mit Überblendungen dazwischen, abhängig von Zoom und Position zugleich. Als DOM-Knoten hieße das: Hunderte Elemente, die Größe, Form und Deckkraft im Frame-Takt ändern.
- Der Untergrund ist ein Raster. Die Farbterritorien sind ein Dichtefeld. Auf einer Canvas sind sie eine kleine Bitmap, gezeichnet durch dieselbe Kamera wie die Fotografien, und können deshalb nicht gegen sie verrutschen.
- Es brauchte keine Bibliothek. Kein Three.js, kein WebGL, kein D3, keine Physik-Engine. Canvas2D reicht in dieser Größenordnung.
WebGL-Experimente für Atmosphäre-Effekte habe ich gebaut. Sie wurden wegen ihrer Wirkung entfernt, nicht wegen der Performance.
Der Preis ist real. Pixel haben keine Semantik: keinen Fokus, keinen zugänglichen Namen, keine Trefferfläche. Alles, was ein Element umsonst mitbrächte, habe ich entweder nachgebaut (Hit-Testing, ein per Tastatur bedienbares Element für die ganze Bühne), an andere Stelle verlegt (Beschriftungen, das Seitenpanel, eine Ansage der Auswahl) oder habe es nicht (Semantik pro Fotografie und räumliche Tastaturnavigation, siehe unten).
React besitzt die Absicht, Refs besitzen die Frames
Die Trennung, die ich durchgesetzt habe, folgt der Häufigkeit:
- React-State, ändert sich, wenn Besucher etwas entscheiden: das ausgewählte Foto, seine Nachbarn, das Seitenpanel, projizierte Mengen.
- React-State, ändert sich nur, wenn das Foto unter dem Zeiger wechselt: das Foto unter dem Zeiger.
- Eine Ref, ändert sich bei jedem Zeiger-, Mausrad- und Touch-Event: die Kamera (Offset und Maßstab).
- Refs, ändern sich in jedem Frame, solange etwas animiert: Hervorhebungsrampen, Kamera-Easing, Position des Leuchtens.
- Refs, einmal pro Layout berechnet: Abstände, Dichtefeld, Farbterritorien.
- Refs, einmal pro Fotografie gefüllt: geladene Bilder, Sprite-Caches.
Die Canvas-Komponente hat überhaupt keinen useState. Props kommen über normales Rendering an und werden nach jedem Commit in Refs kopiert, sodass die Zeichenschleife immer die zuletzt committeten Werte liest. Ein Render, den React verwirft, erreicht nie den Bildschirm. Befehle laufen in die Gegenrichtung über ein imperatives Handle: Korpus einpassen, eine Nachbarschaft rahmen, einen Schritt hineinzoomen. Die Elternkomponente bittet die Canvas, die Kamera zu bewegen; sie hält die Kamera nie selbst.
Das hat eine sichtbare Folge. Die Zoom-Buttons können an der Zoomgrenze keinen deaktivierten Zustand zeigen, denn dafür müsste die Kamera nur zum Ausgrauen eines Buttons nach React kopiert werden. Stattdessen begrenzen sie stillschweigend.
Gemessen mit einem Produktions-Build und den echten 381 Fotografien:
- Schwenken, Zoomen per Mausrad oder Button, Größenänderung und Leerlauf lösen null React-Renders der Konstellation aus.
- Hover rendert nur, wenn das Foto unter dem Zeiger wechselt. Das waren 23 Renders bei 61 Zeigerbewegungen quer über die ganze Karte.
Der React Compiler ist in diesem Repository aktiv. Er kompiliert die React-Seite des Features (Bühnen-Besitzer, Seitenpanel, Steuerung). Die Canvas-Komponente kompiliert er nicht, wegen Zählern, die innerhalb von Closures verändert werden. Das kostet hier nichts, weil die Canvas keine reaktive Arbeit leistet, die sich zu memoisieren lohnte.
Koordinaten und Kamera
Es gibt vier Koordinatenräume und nur eine Transformation zwischen den ersten beiden:
- UMAP-Raumbeliebige Einheiten · zwischen Neuaufbauten ausgerichtet
- KameraEinpassen: scale = min((W−2p)/spanX, (H−2p)/spanY) · { offsetX, offsetY, scale }, scale ∈ [0,5; 32] × Einpassung
- CSS-Pixelsx = x · scale + offsetX · die Canvas-Box der Bühne
- Gerätepixelctx.scale(dpr), dpr ≤ 2 · der Backing Store der Canvas
- Die Kamera besteht aus drei Zahlen. Schwenken addiert das Zeiger-Delta zum Offset.
- Zoomen per Mausrad multipliziert den Maßstab mit
exp(−deltaY × 0,0015)und hält den Punkt unter dem Cursor fest. Ein Pinch tut dasselbe um den Mittelpunkt der beiden Finger. - Die Buttons gehen genau eine Mausrad-Raste weiter, etwa 1,16×, mit 400 ms Easing.
- Der Zoom ist begrenzt auf das Halbe bis 32-Fache des Maßstabs, der das ganze Korpus einpasst.
Zwei Regeln machen die Kamera vorhersehbar:
- Bis Besucher die Kamera berühren, passt sie sich bei jeder Größenänderung der Bühne neu ein. Die erste Messung eines frisch eingehängten Elements ist oft noch nicht seine endgültige Größe. Sobald jemand schwenkt oder zoomt, gehört die Kamera ihm, und nichts bewegt sie ohne ausdrückliche Aktion.
- Eine Auswahl bewegt die Kamera nur um die minimale Strecke, die das Foto bequem im Bild hält, und ändert nie den Zoom. Wer ein Foto anklickt, hat nicht verlangt, woanders hingebracht zu werden.
Das Device-Pixel-Ratio ist auf 2 begrenzt. Auf einem Telefon mit Ratio 3 spart das mehr als die Hälfte der Backing-Store-Pixel, bei einem Unterschied, den ich auf kleinen Sprites nicht sehen konnte.
Eine Frame-Schleife
Jede Bewegung läuft über eine einzige requestAnimationFrame-Kette. Jeder Tick:
- schreitet das Kamera-Easing (400 ms), die Hervorhebung bei Hover und Auswahl (160 ms) und das Leuchten fort, das der ausgewählten Nachbarschaft folgt;
- lässt einige frisch geladene Fotografien erscheinen;
- zeichnet einmal neu;
- plant sich nur dann erneut ein, wenn sich noch etwas bewegt.
Eine zweite Kette gibt es nie. Zwei unabhängige Schleifen könnten zwei Subsysteme im selben Browser-Frame je einmal zeichnen lassen.
Die Ausnahme ist das Sternenfeld. In der Gesamtansicht funkeln die Sterne langsam, mit Perioden von mehreren Sekunden, deshalb bleibt die Schleife aktiv, solange Sterne sichtbar sind, gedrosselt auf etwa 30 Zeichnungen pro Sekunde. Eine mehrsekündige Bewegung alle 33 ms abzutasten ist von 60 fps nicht zu unterscheiden und halbiert die Leerlaufkosten. Sobald Fotografien den Bildschirm übernehmen, endet die Schleife von selbst. Ist reduzierte Bewegung eingestellt, sind die Sterne statisch und die Seite zeichnet im Leerlauf nichts: 0 Zeichnungen in fünf Sekunden, gegenüber 148 ohne diese Einstellung.
Nicht jede Zeichnung läuft über die Schleife. Ziehen und Mausrad-Zoom zeichnen direkt im Event-Handler neu, damit das Bild dem Zeiger nie einen Frame hinterherhinkt.
Die Schleife hat außerdem einen Lebenszyklus, nicht nur einen aktuellen Frame. Bilder treffen auch noch ein, nachdem die Ansicht geschlossen wurde, und jedes davon forderte früher ein Neuzeichnen an. Beim Schließen der Ansicht wird die Schleife entsorgt und lehnt jede spätere Anforderung ab; ein spät eintreffendes Bild kann sie nicht mehr neu starten.
Sterne, Kreise, Fotografien
Wie jede Fotografie aussieht, ist eine reine Funktion, für jede Fotografie in jedem Frame neu berechnet. Sie nimmt den aktuellen Zoom relativ zur Einpassung des Korpus und den Abstand der Fotografie vom Aufmerksamkeitszentrum: dem ausgewählten Foto, falls es eines gibt, sonst der Mitte der Bühne.
- Unterhalb von etwa 1,1× der Einpassung ist alles ein Stern.
- Zwischen etwa 1,1× und 2,2× blenden Fotografien nahe dem Aufmerksamkeitszentrum als Kreise ein, während ihre Sterne ausblenden.
- Von etwa 3,5× bis 7× werden Kreise zu abgerundeten Rahmen im echten Seitenverhältnis des Fotos. Das Seitenverhältnis stammt aus gespeicherten Abmessungen, die Form stimmt also, bevor das Bild überhaupt geladen ist.
Zwei Verfeinerungen halten den Übergang allmählich:
- Die Schwelle jeder Fotografie ist leicht verschoben, je nachdem, wie viel Platz sie um sich hat, damit die Karte nicht in einem Schritt fotografisch wird.
- Das ausgewählte Foto und seine Nachbarn erhalten einen Mindestwert, damit sie zuerst erscheinen.
Mit der Maus wird zusätzlich der Bereich um den Zeiger vorgezogen; bei Touch gibt es das nicht.
Dieselbe Funktion bestimmt die Trefferfläche. Eine Fotografie, die gerade als Bild gezeichnet wird, bekommt ein bildgroßes Ziel; ein Stern behält 14 Pixel. Weil die Klickbehandlung genau das aufruft, was auch das Zeichnen aufruft, trifft man immer, was man sieht.
Das für alle 381 Fotografien zu berechnen kostet etwa 49 µs pro Frame, gemessen außerhalb des Browsers. Deshalb gibt es keinen räumlichen Index und keine Virtualisierung: Ein vollständiger Durchlauf über das Korpus in jedem Frame ist billiger als die Pflege von beidem.
Die Fotografien laden
Bilder sind die eigentlichen Kosten dieser Ansicht, und sie waren auch die Quelle ihres schlimmsten gemessenen Problems.
Was geladen wird. Die Canvas nutzt die vorhandene 800-Pixel-Vorschaustufe jedes Fotos, in der WebP-Variante. Es gibt kein eigenes Canvas-Bild, das erzeugt oder gespeichert werden müsste. Ausgeliefert wird sie mit einem einjährigen, unveränderlichen Cache-Header und in die Canvas gezeichnet, mittig auf die aktuelle Form zugeschnitten. Auf der Bühne gibt es keine <img>-Elemente.
Wann. In der Eröffnungsansicht wird nichts geladen; das ganze Korpus als Sterne braucht keine Bilder (0 Anfragen, gemessen). Das Laden beginnt kurz bevor eine Fotografie als solche sichtbar wird. Es ist auf den sichtbaren Bereich plus einen Rand begrenzt, und die Auswahl und ihre Nachbarn werden mit hoher Priorität angefordert.
Wie sie erscheinen. Eine frühe Version lud jede Vorschau, sobald sich die Ansicht öffnete. Mit damals 221 Fotografien erzeugten die eintreffenden Bilder eine einzige 723-ms-Aufgabe im Main Thread, während der das Ziehen einfror. Zwei Änderungen haben das behoben:
- nichts lädt vor dem ersten Zoom;
- frisch geladene Fotografien erscheinen höchstens sechs pro Frame, über dieselbe Frame-Schleife.
Lange Aufgaben beim Laden fielen von 881 ms auf null. Ich habe auch versucht, das Dekodieren mit img.decode() aus dem Main Thread zu verlagern. Das war messbar schlechter (vier lange Aufgaben, bis zu 386 ms Verzögerung beim Ziehen), also habe ich es zurückgenommen.
Was es weiterhin kostet. Geladen wird vor dem Zeichnen. Der erste Zoomschritt aus der Gesamtansicht fordert 63 Vorschaubilder an; nach einem tiefen Zoom sind 261 von 381 angefordert, etwa 16 MB, während gleichzeitig 160–180 als Fotografien gezeichnet werden. Die eigentlichen Kosten liegen aber nicht in diesen Referenzen. Mein eigener Bild-Cache hält weniger als 20 MB kodierter Daten. Was wächst, ist der Cache des Browsers für dekodierte Bilder: Laut Chromiums Speicherbuchführung stieg er über ein Hineinzoomen und ein paar Schwenks von 76 MB auf etwa 440 MB, und der Browser gab ihn später von selbst auf etwa 210 MB frei. Die Größe kommt daher, dass die Vorschaubilder größer sind als nötig: Sie haben 800 Pixel, Sprites werden aber nie größer als 112 CSS-Pixel gezeichnet. Statt Bilder in JavaScript zu verwerfen, was diesen Cache gar nicht berührt, lohnt sich deshalb ein kleineres, eigenes Vorschaubild für die Canvas. Das habe ich zurückgestellt, bis das Archiv oder ein Profil auf einem echten Gerät es verlangt.
Responsiv und mobil
Die Bühne ist quadratisch, auf dem Desktop höchstens 788 Pixel breit, mit dem Seitenpanel daneben. Auf Tablets wandert das Panel darunter. Auf Telefonen weicht das Quadrat 70 % der Viewport-Höhe, mindestens 480 Pixel, weil ein Quadrat auf einem Telefon im Hochformat die Höhe verschenkt.
Sonst gibt es keine Sonderfälle. Kamera, Zoomgrenzen und Sprite-Größen sind alle relativ zur Einpassung und zur Bühne, Telefon und Desktop erreichen dieselbe Darstellung also beim selben relativen Zoom.
Touch brauchte echte Änderungen:
- Gesten. Ein Finger verschiebt, zwei Finger pinchen, ein Tippen wählt aus. Ein Pinch hält den Punkt unter den Fingern fest, dieselbe Regel deckt also Zoomen und Verschieben mit zwei Fingern ab. Hover gibt es nicht, und der Zeigerfolge-Effekt existiert bei Touch schlicht nicht, sodass nach einem Tippen nichts hervorgehoben bleibt.
- Scrollen abfangen. Browser behandeln die Mausrad- und Touch-Move-Listener von React als passiv, das heißt, sie können nicht verhindern, dass die Seite hinter der Canvas scrollt oder zoomt. Diese Listener hängen deshalb nativ an der Canvas und sind nicht passiv, zusammen mit
touch-action: none. Bei einem gemessenen Pinch hat sich die Seite nicht bewegt. - Der Preis. Ein Ziehen, das auf der Bühne beginnt, kann die Seite nie scrollen, deshalb lässt die Bühne 30 % des Bildschirms frei.
Auf einem emulierten Telefon mit vierfach verlangsamter CPU öffnet sich die Ansicht mit einem langen Frame von etwa 300–360 ms. Ein CPU-Profil teilt ihn auf zwischen der Rendering-Arbeit des Browsers und dem Einhängen der Canvas durch React: die einmalige Geometrie, die Stern-Sprites und die ersten Zeichnungen. Einen einzelnen Teil, der sich zu optimieren lohnt, gibt es noch nicht, also habe ich ihn gelassen.
Barrierefreiheit
Räumliche Nähe lässt sich nicht nicht-visuell machen. Zugänglich machen lässt sich der Inhalt, zu dem die Karte führt, und der größte Teil dieses Wegs existierte schon. Sobald eine Fotografie ausgewählt ist, bietet das Seitenpanel ihre Aktionen an:
- die Vollbildansicht, die mit Zurück und Weiter durch die visuelle Nachbarschaft des Fotos geht;
- eine visuelle Reise, die von Nachbar zu Nachbar geht und die Karte mitbewegt.
All das sind gewöhnliche Buttons. Es fehlte nur ein Weg, die erste Auswahl ohne Zeiger zu treffen.
Deshalb ist die Bühne ein einziger nativer Button. Sein Name sagt, was er tut, „Foto in der Mitte der Karte auswählen“, und seine Beschreibung sagt, was die Karte zeigt:
Gesamtes Portfolio, angeordnet nach visueller Ähnlichkeit. Fotografien werden nach visueller Ähnlichkeit zueinander positioniert. Die Position ist veranschaulichend und keine exakte Messung.
Der Tastaturpfad sieht so aus:
- Wird die Bühne per Tastatur fokussiert, hebt sie die Fotografie hervor, die der Mitte am nächsten liegt, denselben Punkt, um den die Zoom-Buttons zoomen.
- Enter oder Leertaste wählen sie aus. Eine höfliche Live-Region sagt die Auswahl an, und der Name des Buttons wechselt zu „Ausgewähltes Foto öffnen“, ein erneutes Drücken öffnet das Foto also, genau wie ein zweiter Klick.
- Tab führt weiter ins Seitenpanel und dann zu den Zoom-Buttons.
- Escape schließt die Vollbildansicht und gibt den Fokus an die Karte zurück.
Was ich nicht gebaut habe:
- Keine Pfeiltasten-Navigation durch den 2D-Raum und keine versteckte Liste aller 381 Fotografien. Diese Liste würde nur das Raster wiederholen.
- Die eigene Idee der Karte, welche Fotografien beieinanderliegen, bleibt visuell. Erreichbar sind die Beziehungen stattdessen über die Nachbarschaftsansichten.
Geprüft habe ich das am Accessibility-Baum des Browsers, noch nicht mit VoiceOver oder NVDA.
Messungen
Produktions-Build, die echte Datenbank mit 381 Fotos, Headless Chromium mit 1440×900 und Device-Pixel-Ratio 2. Bildraten nenne ich nicht, weil ein Headless-Browser keinen echten Display-Takt hat.
- Ansicht öffnen: 1 React-Render, 2 lange Frames (>50 ms; der längste 100 ms). Keine Vorschaubilder angefordert.
- Leerlauf: 0 Renders, 0 lange Frames. Etwa 30 Zeichnungen pro Sekunde für die Sterne; keine bei reduzierter Bewegung.
- Schwenken (40 Zeigerbewegungen): 0 Renders, 0 lange Frames.
- Mausrad-Zoom (12 Schritte): 0 Renders, 0 lange Frames.
- Hover über die Karte (61 Bewegungen): 23 Renders, 1 langer Frame (67 ms). Gerendert wird nur, wenn das Foto unter dem Zeiger wechselt.
- Foto auswählen: 3 Renders, 0 lange Frames. Nachbarn und Seitenpanel laden.
- Fenstergröße ändern: 0 Renders, 0 lange Frames. Über einen ResizeObserver.
Callbacks der Frame-Schleife, einschließlich Neuzeichnen, blieben auf dem Desktop im 95. Perzentil bei höchstens 1,7 ms. Die Layout-Antwort ist 23 KB groß. Der Canvas-Code, der erst beim ersten Öffnen der Ansicht geladen wird, hat komprimiert 15 KB.
Was die Untersuchung gefunden hat
Für diesen Artikel habe ich das Feature vermessen. Dabei kamen echte Fehler zutage, und die habe ich vor der Veröffentlichung behoben:
- Eine Frame-Schleife, die die Ansicht überlebte. Verließ man die Karte, während noch Vorschaubilder luden, startete ein spät eintreffendes Bild die Schleife neu, obwohl die Canvas schon weg war. Zwanzig Sekunden später lief sie noch in jedem Frame. Jetzt wird die Schleife mit der Ansicht entsorgt; im Produktions-Build erneut gemessen, läuft nichts weiter.
- Ein abgebrochener Zeiger zählte als Klick. Das
pointercanceldes Browsers wurde wiepointerupbehandelt, sodass ein unterbrochener Touch ein Foto auswählen oder öffnen konnte. Ein Abbruch räumt jetzt nur noch auf. - Ein Pinch, der auch verschob. Die Zeiger-Events des letzten Fingers verschoben die Karte, während der Pinch zoomte. Ein Foto unter einem auseinandergezogenen Pinch rutschte etwa 100 Pixel zur Seite; jetzt bleibt es unter den Fingern.
- Ein instabiler Wert und ein paar veraltete Kommentare. Die Brotkrumen der Reise wurden bei jedem Render neu erzeugt, und drei Kommentare beschrieben Verhalten, das der Code nicht mehr hatte.
Jede Korrektur hat einen Regressionstest, außer dem Pinch, den ich im Browser gemessen habe. Was bleibt, steht oben: die Kosten dekodierter Bilder, der lange Frame auf langsamen Telefonen und die Grenzen des Tastaturpfads.
Abwägungen
- Canvas statt Elemente. Das DOM bleibt gleich groß, egal wie viele Fotografien es gibt, und die Zoomübergänge sind stufenlos. Im Gegenzug haben die Fotografien keine eingebaute Semantik, keinen Fokus und keine Trefferflächen. Die Bühne bekommt einen Button, und die Beziehungen erreicht man stattdessen über das Seitenpanel.
- Canvas2D statt WebGL. Keine Shader-Pipeline, zum Preis von Rasterisierung auf der CPU. In dieser Größenordnung war das nie der Engpass, den ich gemessen habe.
- Die Kamera außerhalb von React. Keine Renders während der Bewegung, aber React sieht die Kamera nicht, ein Zoom-Button weiß also nicht, dass er an der Grenze ist.
- Offline-Layout. Die Karte ist stabil und der Client günstig, aber ein neues Foto erscheint erst nach einem Neuaufbau.
- Keine Virtualisierung. Ein einfacher Durchlauf pro Frame, was funktioniert, weil 381 wenig ist.
- Eine vorhandene Vorschaustufe wiederverwenden. Nichts Neues zu erzeugen, zu speichern oder zu cachen, zum Preis von mehr geladenen Pixeln, als die Canvas je zeichnet.
Was ich bei größerem Umfang ändern würde
Nicht die Arbeit pro Frame bricht hier zuerst, sondern die Arbeit pro Layout. Mehrere Schritte der abgeleiteten Geometrie vergleichen jede Fotografie mit jeder anderen. Auf synthetisch vergrößerten Versionen des echten Layouts:
- 381 Fotografien (heute): Geometrie einmalig ≈20 ms, Darstellungsberechnung ≈0,05 ms pro Frame.
- 1.000 Fotografien: einmalig ≈60 ms, ≈0,1 ms pro Frame.
- 4.000 Fotografien: einmalig ≈650 ms, ≈0,4 ms pro Frame.
Irgendwo jenseits von tausend Fotografien würde ich:
- diese Geometrie offline neben dem Layout oder in einem Worker berechnen;
- ein Vorschaubild in Canvas-Größe (etwa 256 Pixel) statt des 800-Pixel-Bildes anfordern;
- Bilder freigeben, die eine Weile weit vom Viewport entfernt waren;
- einen räumlichen Index für das Hit-Testing nur dann einführen, wenn ein Profil zeigt, dass er etwas bringt.
Unabhängig vom Umfang würde ich den Tastaturpfad mit echten Screenreadern testen und Speicher sowie den Eröffnungs-Frame auf einem echten Mittelklasse-Telefon statt einem emulierten messen.
Weiterlesen
Die Fallstudie zur Fotosuche erklärt die Bildähnlichkeitssuche, die die Nachbarn jeder Fotografie liefert. Die Karte selbst findet sich auf der Seite Fotografie unter der Linse „Visuelle Verbindungen“.