Files
posts/video/komponenten.html
T
dejanandClaude Opus 5 e02e87b7e2 Video: das Wabenfeld baut sich auf, gerenderte Dateien raus
Das Video ist geschnitten. Die Einblendungen liegen auf einer externen
Platte, im Repo bleibt die Werkbank: 381 Dateien und 24 MB gerenderte
Ausgabe sind raus, png/ und sequenz/ legt bauen.mjs beim naechsten Lauf
selbst wieder an.

WABENFELD
Es blendete als Ganzes auf, und das ist bei der einen Zeichnung, die
etwas behauptet, zu wenig. Jetzt steht zuerst die Nabe, dann wachsen die
Waben in fuenf Wellen nach aussen, und die Straenge ziehen jeweils kurz
nach ihrer Zelle nach. Rueckwaerts zieht sich das Feld in die Marke
zurueck, statt von aussen zu zerfallen.

Die Bewegung ist die introTimeline aus HoneycombSection.tsx, uebernommen
und nicht erfunden: scale 0,16 auf 1, back.out(1.75), gestaffelt nach
Abstand zur Mitte, danach die Straenge mit eigener Deckungsrampe. Weil
unsere Belegung nur fuenf verschiedene Abstaende kennt, sind es fuenf
Wellen und nicht achtzehn Einzelereignisse, und das liest sich als eine
Bewegung.

Dafuer brauchte der Zeitverlauf ein zweites Mass. Bisher frass der
Verzug vom Rest der Zeit: die letzte von achtzehn Zellen haette ein
Zehntel der Zeit fuer dieselbe Bewegung gehabt und waere hereingeschnappt,
waehrend die erste hereinschwebt. Mit data-d bekommt ein Element ein
eigenes Fenster fester Laenge. Ohne data-d bleibt alles wie es war, die
anderen neun Komponenten laufen unveraendert durch den alten Pfad.

50 Bilder statt 12, bei 25 fps also zwei Sekunden herein und 1,2 hinaus.
Die Bewegung ist bei 0,94 fertig, die letzten Bilder stehen still. Masse
und Ausschnitt sind unveraendert, 2168 x 1906.

DIE PNGS WAREN IN DER ERSATZSCHRIFT
KOMP_BEREIT wartete auf document.fonts.ready, bevor die Karte im DOM
stand. Zu dem Zeitpunkt braucht die Seite keine Manrope, es laeuft kein
Ladevorgang, und das Versprechen loest sofort auf -- erfuellt, ohne dass
ein Byte Schrift da waere. Danach wurde gebaut, und ob die Schrift bis
zur Messung ankam, entschied der Zufall: dieselbe Komponente
hintereinander gemessen kam abwechselnd 984 und 1010 px breit heraus.

Das ist die Falle aus rules.md 4, nur eine Ebene frueher als dort
beschrieben. document.fonts.load() holt eine Schriftlage unabhaengig
davon, ob gerade jemand sie benutzt, erst danach ist ready eine Aussage.
Dieselbe Wette steckte in der Wortmarke von logo-bug, einem img mit
width:auto, deshalb wartet der Aufbau jetzt auch auf die Bilder.

Sieben der zehn zuletzt gebauten Dateien waren betroffen und 2 bis 4
Prozent zu schmal. Die reinen Zeichnungen verraten es nicht, ihr SVG hat
feste Masse -- ring-kern war gegen einen frischen Render bildgleich.

STANDBILD KOMMT AUS DER SEQUENZ
Der Dreiteiler im Schnitt haengt daran, dass -ein, Standbild und -aus an
den Stoessen dasselbe Bild zeigen. Als zweite Aufnahme war das bisher nur
wahr, weil es zufaellig stimmte. Beim Wabenfeld stimmte es nicht mehr:
dort tragen die Zellen waehrend der Bewegung ein transform, und nach dem
Wegnehmen rastert Chrome eine Spur anders als bei einem Element, das nie
eines hatte. Der Unterschied lag bei einer Stufe auf verstreuten Pixeln,
also unsichtbar, aber die Zusage war weg. bauen.mjs schreibt das
Standbild jetzt aus dem letzten Bild der Einblendung: dieselbe Datei
statt derselbe Anblick.

NEBENBEI
- Bauchbinde Betrieb, Chip "Vierte Generation" und Endkarte sind raus.
  Die Bauchbinde liess sich ueber ?name= und ?rolle= ohnehin aus der
  ersten erzeugen, ein Schlussbild gehoert in den Schnitt und nicht als
  Einblendung darauf.
- Das Wabenfeld steht ohne das blasse Feld aus leeren Waben. Auf dem
  weissen Blatt traegt das die Zeichnung, ueber Videomaterial ist ein
  Schleier aus halbdurchsichtigen Sechsecken im Weg. Der Ausschnitt wird
  seitdem aus den belegten Waben gerechnet. In post3.html bleibt beides.
- strangForm() heisst hier strangPfad() und liefert nur den Pfad: die
  Form muss je Bild neu gerechnet werden, weil ein Ende wandert. In den
  Post-Seiten bleibt strangForm(), die Rechnung darin ist dieselbe.
- liste.json und README stehen auf dem Stand der zehn Komponenten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:29:00 +02:00

1116 lines
57 KiB
HTML
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.
<meta charset="utf-8">
<title>HiveGrid · Video-Komponenten</title>
<!-- ═══════════════════════════════════════════════════════════════════════════
Einblendungen fuer den Videoschnitt.
Anders als die Post-Seiten ist das hier kein Editor, sondern eine Vorlage:
die Seite zeigt genau eine Komponente auf durchsichtigem Grund, in ihrer
eigenen Groesse. bauen.mjs faehrt sie der Reihe nach ab und legt daraus
PNGs mit Alphakanal und Bildsequenzen an.
Warum durchsichtig und nicht auf einer Leinwand: eine Einblendung wird im
Schnitt ueber das Bild gelegt und dort frei geschoben und skaliert. Eine
feste Leinwand von 1920 x 1080 waere zu 90 Prozent leer, und der Schnitt
muesste sie erst wieder wegrechnen.
Aufruf:
?k=bauchbinde-noah die Komponente im gehaltenen Zustand
?k=bauchbinde-noah&t=0.4 im Verlauf ihrer Einblendung
?k=bauchbinde-noah&text=... eigener Text, siehe felder je Komponente
?alle=1 Kontaktbogen mit allen, zum Durchsehen
Die Bildsprache ist die von post2-alt.html, Theme modern: weisse Karten
mit Haarlinie und weichem Schatten, ein gelber Akzent, Manrope. Die
Zeichen kommen aus demselben Feather-Satz wie die Oberflaeche. Wer hier
etwas aendert, sollte dort nachsehen -- rules.md 1a beschreibt, was das
Theme ausmacht.
══════════════════════════════════════════════════════════════════════ -->
<link rel="stylesheet" href="../assets/fonts/manrope.css">
<style>
:root {
--gelb: #F1B729;
--gelb-hell: #FFE082;
--gelb-dunkel: #3E2C0A;
--teal: #0D6E6E;
--teal-hell: #E8F4F4;
--tinte: #1A1A1A;
--text2: #555555;
--kante: #D5DBE1;
--karte: #FFFFFF;
--grund: #F6F8FA;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
/* Durchsichtig bis in die Wurzel. Chrome bekommt beim Rendern zusaetzlich
--default-background-color=00000000 mit; ohne das hier waere trotzdem
Weiss darunter. */
html, body { background: transparent; }
body {
font-family: 'Manrope', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Die Buehne hat exakt die Groesse der Komponente. Kein Rand, keine Mitte:
was hier steht, ist spaeter das PNG. */
#buehne { position: relative; overflow: visible; }
/* ─── Karte ──────────────────────────────────────────────────────────────
Die Grundform des Themes: weiss, Haarlinie, weicher Schatten. Ueber
Videomaterial traegt der Schatten mehr als in einem Post, weil darunter
kein ruhiges Blatt liegt, sondern eine Baustelle. Deshalb eine Spur
kraeftiger als in rules.md 5 notiert. */
.karte {
background: var(--karte);
border: 1px solid var(--kante);
clip-path: polygon(
14px 0, calc(100% - 14px) 0, 100% 14px, 100% calc(100% - 14px),
calc(100% - 14px) 100%, 14px 100%, 0 calc(100% - 14px), 0 14px
);
}
/* Der Schatten sitzt auf der Huelle, nicht auf der Karte. Der Grund steht
in rules.md 3: clip-path beschneidet den Schatten mit, und die Karte
traegt den Chamfer als clip-path. Ein box-shadow auf ihr war deshalb
schlicht nicht da -- am gerenderten PNG nachgemessen, die Deckung brach
an der Kartenkante von 255 auf 0 ab, ohne einen einzigen Zwischenwert.
drop-shadow statt box-shadow folgt der Alphamaske und legt sich damit um
die abgeschnittenen Ecken herum, statt um ein Rechteck. Etwas kraeftiger
als der Wert des Themes (rules.md 5: dy 7, 10 Unschaerfe, 14 Prozent):
unter einer Slide liegt ein ruhiges weisses Blatt, unter einer
Einblendung eine Baustelle. */
.schatten { filter: drop-shadow(0 9px 15px rgba(16, 24, 40, .22)); }
/* ─── Bauchbinde ─────────────────────────────────────────────────────────
Nachbau des Namensschilds von der Teamkarte aus post2-alt.html. Dort
sitzt es in der Ecke eines Fotos, hier steht es frei. Groesser gesetzt:
im Post wird die Slide auf gut 400 px im Feed geschrumpft, im Video
laeuft sie auf einem Fernseher. */
.bb {
display: flex; align-items: center; gap: 26px;
padding: 30px 44px 30px 32px;
background: var(--karte);
}
.bb .marke { width: 76px; height: 66px; flex: none; }
.bb .marke svg { display: block; width: 100%; height: 100%; }
.bb .zeilen { display: flex; flex-direction: column; gap: 6px; }
.bb b {
font-size: 46px; font-weight: 800; letter-spacing: -.028em;
line-height: 1.05; color: var(--tinte); white-space: nowrap;
}
.bb small {
font-size: 21px; font-weight: 700; letter-spacing: .16em;
text-transform: uppercase; color: var(--text2); white-space: nowrap;
}
/* Der gelbe Balken an der Kante. Er ist das, was die Bauchbinde auf einem
unruhigen Bild als Marke lesbar macht, nicht die Schrift. */
.bb-balken {
position: absolute; left: 0; top: 0; bottom: 0; width: 12px;
background: var(--gelb);
}
/* ─── Satzkarte ──────────────────────────────────────────────────────────
Eine Aussage, sonst nichts. Noah spricht den Satz, die Karte setzt das
eine Wort, an dem er haengt -- deshalb hoechstens ein Akzent, wie in
rules.md 4. */
.satz { padding: 40px 52px; }
.satz p {
font-size: 52px; font-weight: 800; letter-spacing: -.03em;
line-height: 1.12; color: var(--tinte); white-space: nowrap;
}
.satz p .a { color: var(--gelb); position: relative; }
/* Der Balken unter dem Akzentwort, wie im Theme modern. Masse in em, damit
er jeder Schriftgroesse folgt. */
.satz p .a::after {
content: ""; position: absolute; left: 0; right: 0; bottom: -.1em;
height: .055em; background: currentColor;
}
.satz .sub {
margin-top: 14px;
font-size: 26px; font-weight: 600; letter-spacing: -.005em;
line-height: 1.35; color: var(--text2); white-space: nowrap;
}
/* ─── Zeilen im Stapel ───────────────────────────────────────────────────
Drei Kaesten untereinander, jeder mit Zeichen. Fuer Aufzaehlungen, die
Noah spricht -- Stammdaten, Auftraege, Buchhaltung. */
.zeile {
display: flex; align-items: center; gap: 22px;
padding: 22px 34px; margin-bottom: 16px;
background: var(--karte);
}
.zeile:last-child { margin-bottom: 0; }
.zeile .ikon { width: 46px; height: 46px; flex: none; }
.zeile span {
font-size: 32px; font-weight: 700; letter-spacing: -.018em;
color: var(--tinte); white-space: nowrap;
}
/* Teal traegt die Zeichen, Gelb bleibt dem einen Akzent vorbehalten. */
.zeile .ikon svg { display: block; width: 100%; height: 100%; }
/* Die gleiche Zeile in gelb, fuer das, was HiveGrid dazustellt. */
.zeile.an .ikon svg { color: var(--gelb); }
/* ─── Logo-Zeile ─────────────────────────────────────────────────────────
Die Wortmarke fuer die Ecke. Auf einer weissen Kachel, weil sie sonst
auf hellem Videomaterial verschwindet. */
.bug { display: flex; align-items: center; gap: 20px; padding: 20px 30px; }
.bug img { display: block; height: 44px; width: auto; }
.bug .strich { width: 2px; align-self: stretch; background: var(--kante); }
.bug span {
font-size: 19px; font-weight: 700; letter-spacing: .15em;
text-transform: uppercase; color: var(--text2); white-space: nowrap;
}
/* ─── Zeichenflaeche ─────────────────────────────────────────────────────
Die uebernommenen Zeichnungen fuellen ihre Buehne selbst aus. Kein
Layout-CSS hier -- in post2-alt.html hat genau das dreimal zugeschlagen
und ein fertiges SVG in eine Rasterzelle gequetscht. */
.zeichnung svg { display: block; width: 100%; height: auto; overflow: visible; }
/* ─── Kontaktbogen ───────────────────────────────────────────────────────
Nur zum Durchsehen, kommt nie in ein PNG. Karo statt Weiss, sonst sieht
man bei weissen Karten nicht, wo sie aufhoeren. */
body.bogen {
background:
repeating-conic-gradient(#e9edf2 0 25%, #f7f9fb 0 50%) 0 0 / 40px 40px;
padding: 40px;
}
body.bogen #buehne { display: flex; flex-wrap: wrap; gap: 40px; align-items: flex-start; }
.bogen-zelle { position: relative; }
.bogen-zelle .bez {
position: absolute; left: 0; top: -26px;
font-size: 15px; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: #7a8699; white-space: nowrap;
}
</style>
<div id="buehne"></div>
<script>
"use strict";
/* Ein Fehler auf oberster Ebene wuerde die Seite still leer lassen, und
bauen.mjs bekaeme lauter durchsichtige PNGs ohne Hinweis worauf. Also
sichtbar machen. */
function melden(was) {
document.title = "FEHLER: " + was;
const d = document.createElement("pre");
d.style.cssText = "position:fixed;left:0;top:0;background:#B3261E;color:#fff;" +
"padding:12px;font:14px monospace;z-index:99;white-space:pre-wrap";
d.textContent = "Fehler: " + was;
document.body.appendChild(d);
}
window.addEventListener("error", e => melden(e.message || e.error));
/* Der Aufbau laeuft in einer async-Funktion. Was dort fliegt, ist eine
abgelehnte Promise und kein error-Ereignis -- ohne diesen zweiten Horcher
bricht die Seite still nach der letzten gelungenen Komponente ab, und man
sucht den Grund im Rendern statt im Bauen. */
window.addEventListener("unhandledrejection", e =>
melden((e.reason && (e.reason.stack || e.reason.message)) || e.reason));
const n1 = v => Math.round(v * 10) / 10;
/* ─── Farben ───────────────────────────────────────────────────────────────
Dieselben Werte wie in rules.md 2. Als Konstanten und nicht als
CSS-Variablen, weil die Zeichnungen sie in SVG-Attribute schreiben und
dort keine var() steht. */
const GELB = "#F1B729", TINTE = "#1A1A1A", TEXT2 = "#555555",
TEAL = "#0D6E6E", LINIE = "#E5E5E5", KANTE = "#D5DBE1",
KARTE = "#FFFFFF", GRUND = "#F6F8FA";
/* Die Zeichnungen aus post2-alt.html fragen die Variante ab. Hier gibt es nur
die helle: eine Einblendung liegt auf fremdem Bildmaterial, und was darauf
traegt, ist eine weisse Karte, keine dunkle. */
const variant = "hell";
/* ─── Uebernommene Bausteine ───────────────────────────────────────────────
Woertlich aus post2-alt.html und post3.html. Nicht nachgebaut und nicht
verbessert: eine Einblendung im Video und eine Slide im Feed muessen
dieselbe Marke zeigen, und zwei Fassungen derselben Zeichnung laufen
frueher oder spaeter auseinander. Wer an einer Zeichnung etwas aendert,
muss an alle drei Dateien denken -- das ist der Preis dafuer, dass jede
Datei fuer sich lauffaehig bleibt (rules.md 1a). */
const CHAMFER_C = 16;
// Der Grund, den die Aussparung der G-Marke zeigt. Im Video liegt die
// Zeichnung auf einer weissen Karte, nicht auf fremdem Bildmaterial.
const SLIDE_GRUND = "#FFFFFF";
function chamfer(x, y, w, h, c) {
return "M" + n1(x + c) + "," + n1(y) +
"H" + n1(x + w - c) + "L" + n1(x + w) + "," + n1(y + c) +
"V" + n1(y + h - c) + "L" + n1(x + w - c) + "," + n1(y + h) +
"H" + n1(x + c) + "L" + n1(x) + "," + n1(y + h - c) +
"V" + n1(y + c) + "Z";
}
function glatt(pts, zu) {
const n = pts.length;
const at = i => pts[zu ? (i + n) % n : Math.max(0, Math.min(n - 1, i))];
let d = "M" + n1(pts[0][0]) + "," + n1(pts[0][1]);
const bis = zu ? n : n - 1;
for (let i = 0; i < bis; i++) {
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
d += "C" + n1(p1[0] + (p2[0] - p0[0]) / 6) + "," + n1(p1[1] + (p2[1] - p0[1]) / 6) +
" " + n1(p2[0] - (p3[0] - p1[0]) / 6) + "," + n1(p2[1] - (p3[1] - p1[1]) / 6) +
" " + n1(p2[0]) + "," + n1(p2[1]);
}
return d;
}
const SCHATTEN_DEFS =
'<defs><filter id="hgschatten" x="-35%" y="-35%" width="170%" height="170%">' +
'<feDropShadow dx="0" dy="7" stdDeviation="10" flood-color="#101828" ' +
'flood-opacity="0.14"/></filter></defs>';
const karteFlaeche = (x, y, w, h, c, fuell) =>
'<g filter="url(#hgschatten)"><path d="' +
chamfer(x, y, w, h, c == null ? CHAMFER_C : c) + '" fill="' + (fuell || KARTE) +
'" stroke="' + KANTE + '" stroke-width="1.2"/></g>';
const G_PUNKTE = "384.5,166.5 336.4,83.2 192.2,166.5 288.3,166.5 240.3,249.7 " +
"144.2,249.7 96.1,166.5 144.2,83.2 336.4,83.2 298,16.8 288.3,0 96.1,0 " +
"0,166.5 96.1,332.9 288.3,332.9";
function gMarke(cx, cy, hoehe, farbe) {
const s = hoehe / 332.9;
return '<g transform="translate(' + n1(cx - 384.5 * s / 2) + ',' + n1(cy - hoehe / 2) +
') scale(' + s.toFixed(5) + ')"><polygon points="' + G_PUNKTE + '" fill="' +
farbe + '"/></g>';
}
const FEATHER = {
"alert-triangle": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
"check": '<polyline points="20 6 9 17 4 12"/>',
"clipboard-list": '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>',
"columns-3": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/>',
"file-text": '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/>',
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
"folder": '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
"image": '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
"mail": '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
"megaphone": '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
"smartphone": '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
"sparkles": '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>',
"sticky-note": '<path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M15 3v6h6"/>',
"table": '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
"workflow": '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
"x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
"rocket": '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'
};
function feather(name, cx, cy, groesse, farbe, staerke) {
const s = groesse / 24;
return '<g transform="translate(' + n1(cx - groesse / 2) + ',' + n1(cy - groesse / 2) +
') scale(' + n1(s) + ')" fill="none" stroke="' + farbe + '" stroke-width="' +
n1((staerke == null ? 2 : staerke) / s) +
'" stroke-linecap="round" stroke-linejoin="round">' + FEATHER[name] + '</g>';
}
function visVerbinden() {
const W = 988, H = 730, CX = 494, CY = 368;
const KERN = 104, RING = 202, SATX = 344, SATY = 262, KACHEL = 88;
const module = [
{ a: 240, t: "KI", i: "sparkles" },
{ a: 300, t: "Chat", i: "message-square" },
{ a: 0, t: "Mitarbeiter", i: "users" },
{ a: 60, t: "Automation", i: "zap" },
{ a: 120, t: "Projekte", i: "columns-3" },
{ a: 180, t: "Formulare", i: "clipboard-list" },
];
// KI hat kein eigenes Zeichen im Satz, deshalb der Funke von Slide 5 alt.
module[0].ikon = (x, y) => funkeForm(x, y, 20);
let raus = "";
// Der Ring: HiveGrid liegt um den Kern, nicht an seiner Stelle.
raus += '<circle cx="' + CX + '" cy="' + CY + '" r="' + RING + '" fill="none" stroke="' +
GELB + '" stroke-width="3"/>';
// Speichen und Kacheln.
module.forEach(m => {
const r = m.a * Math.PI / 180;
const sx = CX + Math.cos(r) * SATX, sy = CY + Math.sin(r) * SATY;
const vx = sx - CX, vy = sy - CY, vl = Math.hypot(vx, vy) || 1;
const ux = vx / vl, uy = vy / vl;
raus += '<line x1="' + n1(CX + ux * RING) + '" y1="' + n1(CY + uy * RING) +
'" x2="' + n1(sx - ux * (KACHEL / 2 + 10)) + '" y2="' + n1(sy - uy * (KACHEL / 2 + 10)) +
'" stroke="' + LINIE + '" stroke-width="2"/>';
raus += '<circle cx="' + n1(CX + ux * RING) + '" cy="' + n1(CY + uy * RING) +
'" r="7" fill="' + GELB + '"/>';
raus += karteFlaeche(sx - KACHEL / 2, sy - KACHEL / 2, KACHEL, KACHEL, 12, GRUND);
raus += feather(m.i, sx, sy, 42, GELB, 2.3);
const ty = m.a > 180 ? sy - KACHEL / 2 - 16 : sy + KACHEL / 2 + 34;
raus += '<text x="' + n1(sx) + '" y="' + n1(ty) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="24" font-weight="700" ' +
'letter-spacing="-0.01em" fill="' + TINTE + '">' + m.t + '</text>';
});
// Der Kern. Gefuellt und dunkel, damit er als das Bestehende liest.
raus += '<circle cx="' + CX + '" cy="' + CY + '" r="' + KERN + '" fill="' +
(variant === "dunkel" ? "#1A2740" : "#1A1A1A") + '"/>';
raus += '<text x="' + CX + '" y="' + (CY - 34) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="16" font-weight="700" ' +
'letter-spacing="0.18em" fill="rgba(255,255,255,.5)">KERN</text>';
["Dein", "Programm"].forEach((z, i) => {
raus += '<text x="' + CX + '" y="' + (CY + 6 + i * 34) + '" text-anchor="middle" ' +
'font-family="Manrope, sans-serif" font-size="30" font-weight="800" ' +
'letter-spacing="-0.02em" fill="#FFFFFF">' + z + '</text>';
});
/* Die Pille auf dem Ring, wie im Pitch-Diagramm. Sie sitzt auf der Bahn
und macht damit sichtbar, dass HiveGrid der Ring ist und nicht die
Mitte. */
const PB = 94, PH = 72, PX = CX - PB / 2, PY = CY - RING - PH / 2;
raus += karteFlaeche(PX, PY, PB, PH, 14);
raus += gMarke(CX, CY - RING, 42, GELB);
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
}
/* Vierzackiger Funke fuer KI, praezise gezogen. */
function funkeForm(cx, cy, r) {
const k = r * 0.3, p = [];
for (let i = 0; i < 8; i++) {
const a = -Math.PI / 2 + i * Math.PI / 4, rr = i % 2 ? k : r;
p.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
}
return glatt(p, true);
}
/* ─── Das Wabenfeld ───────────────────────────────────────────────────────
Nachbau von HoneycombSection.tsx aus der Website. Die Geometrie ist von
dort uebernommen und nicht nachgemessen: derselbe Umkreisradius, dieselbe
Achsrechnung, dieselben Zellnummern, dieselbe Belegung. Wer die Startseite
kennt, soll dasselbe Bild sehen und nicht eine Anmutung davon.
Drei Dinge fallen gegenueber der Website weg:
- Die Zellen "In Arbeit" (29, 41, 46, 59). Auf der Startseite sind sie ein
Versprechen, im Beitrag waeren sie ein Eingestaendnis. Sie werden nicht
durch etwas anderes ersetzt, sondern sind wieder gewoehnliche leere
Waben.
- Alles Bewegte: Sog, Pakete, Hover. Ein Standbild hat davon nichts.
- Das Feld aus leeren Waben. Die Website fuellt damit einen ganzen
Abschnitt bis Ring 18; hier bleibt nur, was belegt ist. Der Grund steht
bei visWabenfeld(). */
const WABE_R = 62; // Umkreisradius, alles leitet sich davon ab
const WABE_B = 2 * WABE_R; // Breite Spitze zu Spitze
const WABE_ZELLE = 0.84; // laesst die Fuge zwischen zwei Waben offen
const WABE_MITTE = 0.88; // die Nabe steht eine Spur groesser
/* Achsial (q,r) auf Pixel, flat-top. */
const wabeOrt = (q, r) => ({ x: WABE_R * 1.5 * q, y: WABE_R * Math.sqrt(3) * (r + q / 2) });
const WABE_RICHTUNG = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
/* Alle Waben genau `k` Ringe von der Mitte entfernt, im Ringgang. Die
Reihenfolge bestimmt die Zellnummern und darf sich nicht aendern, sonst
sitzen die Marken woanders. */
function wabeRing(k) {
if (k === 0) return [[0, 0]];
const raus = [];
let q = WABE_RICHTUNG[4][0] * k, r = WABE_RICHTUNG[4][1] * k;
for (let seite = 0; seite < 6; seite++) {
for (let schritt = 0; schritt < k; schritt++) {
raus.push([q, r]);
q += WABE_RICHTUNG[seite][0];
r += WABE_RICHTUNG[seite][1];
}
}
return raus;
}
/* Die Zellnummern der Website: 1 die Nabe, 2 bis 7 der innere Ring, 8 bis 19
der Deko-Ring, danach das Feld fortlaufend. Nur so stimmen die Nummern in
WABE_MARKEN mit HONEYCOMB_DEFAULT_LOGO_PLACEMENTS ueberein. */
const WABE_RINGE = 5;
const wabeZellen = (() => {
const raus = [];
for (let k = 0; k <= WABE_RINGE; k++) {
wabeRing(k).forEach(([q, r]) => {
raus.push({ ...wabeOrt(q, r), q, r, ring: k, nr: raus.length + 1 });
});
}
return raus;
})();
/* Die Belegung der Website, Zellnummer auf Datei. `gr` ist deren Skalierung,
`art` der Renderpfad, aus dem sie stammt: eine Feld-Zelle rechnet mit
einer um 1,18 groesseren Grundflaeche als eine Deko-Zelle. Beides
zusammen ergibt bei jeder Marke dieselbe sichtbare Groesse -- Claude
(deko, 1,12) und OpenAI (feld, 0,95) sind die Eichmarken und kommen beide
auf 56,5 px. Wer eine Marke tauscht, rechnet ihren Wert genauso aus:
gr = Grundflaeche des Pfads geteilt durch den Anteil, den die Datei in
ihrer eigenen Flaeche tatsaechlich ausfuellt. */
const WABE_MARKE_FELD = WABE_B * 0.48;
const WABE_MARKE_DEKO = WABE_MARKE_FELD / 1.18;
const WABE_MARKEN = {
9: { datei: "datev.png", art: "deko", gr: 1.10 },
10: { datei: "pandadoc.png", art: "deko", gr: 1.12, radius: 0.18 },
12: { datei: "ms-outlook.svg", art: "deko", gr: 1.12 },
13: { datei: "google-drive.svg", art: "deko", gr: 1.12 },
15: { datei: "claude.svg", art: "deko", gr: 1.12 },
17: { datei: "kwp.png", art: "deko", gr: 1.12 },
18: { datei: "bauhow.png", art: "deko", gr: 1.49 },
19: { datei: "pds.svg", art: "deko", gr: 1.12 },
21: { datei: "aircall.svg", art: "feld", gr: 1.43 },
22: { datei: "onepage.png", art: "feld", gr: 0.96 },
26: { datei: "ms-teams.svg", art: "feld", gr: 0.95 },
28: { datei: "google-calendar.svg", art: "feld", gr: 0.95 },
30: { datei: "openai.svg", art: "feld", gr: 0.95 },
36: { datei: "hallopetra.png", art: "feld", gr: 0.99 },
};
const wabeMarkePfad = m => "../assets/partner/" + m.datei;
/* Drei gelbe Waben ohne Marke direkt an der Nabe. Sie sind auf der Website
genauso gesetzt und der Grund, warum die Mitte nicht allein steht. */
const WABE_LEER_AKTIV = [3, 5, 7];
const wabeAktiv = nr => WABE_MARKEN[nr] != null || WABE_LEER_AKTIV.includes(nr);
/* Die Straenge. Ein Strang ist keine Linie, sondern eine gefuellte Form:
breit dort, wo er wurzelt, duenn in der Mitte. Werte aus der Website. */
const STRANG_FUSS = 14, STRANG_KOPF = 16, STRANG_TAILLE = 4.5, STRANG_TIEF = 8;
const WABE_INNEN = WABE_R * WABE_ZELLE * 0.8660254; // Inkreis einer Wabenflaeche
/* Nur der Pfad, nicht das fertige Element. Anders als in den Post-Seiten
wird die Form hier je Bild neu gerechnet: der Strang waechst, und dabei
wandert eines seiner Enden. Wer die Zeichnung in post2-alt.html oder
post3.html anfasst, aendert dort weiterhin strangForm() -- die Rechnung
darin ist Zeichen fuer Zeichen dieselbe wie hier. */
function strangPfad(a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const l = Math.hypot(dx, dy) || 1;
const ux = dx / l, uy = dy / l, nx = -uy, ny = ux;
const wm = STRANG_TAILLE;
// Kubischer Griff, der in der Mitte genau die Taille ergibt.
const wc = (8 * wm - STRANG_FUSS - STRANG_KOPF) / 6;
const c1x = a.x + ux * l * 0.35, c1y = a.y + uy * l * 0.35;
const c2x = a.x + ux * l * 0.65, c2y = a.y + uy * l * 0.65;
const f = v => v.toFixed(2);
return "M" + f(a.x + nx * STRANG_FUSS) + " " + f(a.y + ny * STRANG_FUSS) +
"C" + f(c1x + nx * wc) + " " + f(c1y + ny * wc) + " " + f(c2x + nx * wc) + " " +
f(c2y + ny * wc) + " " + f(b.x + nx * STRANG_KOPF) + " " + f(b.y + ny * STRANG_KOPF) +
"L" + f(b.x - nx * STRANG_KOPF) + " " + f(b.y - ny * STRANG_KOPF) +
"C" + f(c2x - nx * wc) + " " + f(c2y - ny * wc) + " " + f(c1x - nx * wc) + " " +
f(c1y - ny * wc) + " " + f(a.x - nx * STRANG_FUSS) + " " + f(a.y - ny * STRANG_FUSS) + "Z";
}
/* Der Punkt, der `weit` von einer Wabe aus in Richtung einer anderen liegt.
Die Enden sitzen bewusst unter der Wabenflaeche (STRANG_TIEF), damit die
Naht nicht als Kante zu sehen ist. */
const wabeZu = (von, nach, weit) => {
const dx = nach.x - von.x, dy = nach.y - von.y;
const l = Math.hypot(dx, dy) || 1;
return { x: von.x + (dx / l) * weit, y: von.y + (dy / l) * weit };
};
/* Flat-top-Wabe als Pfad. */
function wabePfad(cx, cy, rr) {
const hh = rr * 0.8660254, f = v => v.toFixed(1);
return "M" + f(cx + rr) + " " + f(cy) + "L" + f(cx + rr / 2) + " " + f(cy + hh) +
"L" + f(cx - rr / 2) + " " + f(cy + hh) + "L" + f(cx - rr) + " " + f(cy) +
"L" + f(cx - rr / 2) + " " + f(cy - hh) + "L" + f(cx + rr / 2) + " " + f(cy - hh) + "Z";
}
/* Hier standen der feste Ausschnitt WABE_VB und die Ausblendung
WABE_MASKE. Beide gehoerten zum Feld aus leeren Waben, und das gibt es in
dieser Fassung nicht mehr: der Ausschnitt wird in visWabenfeld() aus den
belegten Waben gerechnet. Im Post stehen beide weiterhin, dort tragen
sie. */
/* Die Marken-Kaesten in Zeichenkoordinaten, je Slide. Der Export prueft sie
einzeln nach, siehe wabenZonen(). Je Slide und nicht als eine Liste: wer
die Slide kopiert, hat das Feld zweimal, und die zweite Zeichnung haette
die Kaesten der ersten ueberschrieben. */
const wabenMarkenBoxen = new Map();
/* ─── Die Wabe, freigestellt ───────────────────────────────────────────────
Anders als im Post: dort liegt hinter den belegten Waben ein blasses Feld
aus leeren, das nach aussen ausblendet. Auf dem weissen Blatt traegt das
die Zeichnung und gibt ihr Grund. Ueber Videomaterial ist es im Weg -- ein
Schleier aus halbdurchsichtigen Sechsecken auf einer Baustellenaufnahme
sieht nach Fehler aus, nicht nach Absicht.
Hier bleiben deshalb nur die belegten Waben, ihre Straenge und die Marken.
Zwei Dinge fallen damit mit weg:
- Die Ausblendung. Sie war dafuer da, das Feld nach oben und unten
auslaufen zu lassen; ohne Feld haette sie nichts mehr auszublenden
ausser den aeusseren Marken selbst.
- Der feste Ausschnitt WABE_VB. Der Kasten wird jetzt aus den belegten
Waben gerechnet, damit kein leerer Rand stehenbleibt. */
/* Die Staffelung, in Anteilen des Verlaufs. Zwischen der Nabe und der
aeussersten Wabe liegen fuenf verschiedene Abstaende; "welle" verteilt
sie darauf. Mit den Fenstern darunter faengt die letzte Zelle bei 0,46
an und der letzte Strang ist bei 0,94 fertig -- die letzten Bilder stehen
also still, und der Stoss zum Standbild ist ein Halten, kein Schnapp.
Wer hier dreht: welle + nach + strang darf 1 nicht ueberschreiten, sonst
ist die Bewegung am Ende der Sequenz noch unterwegs und das letzte Bild
ist nicht mehr das Standbild. Genau daran haengen die unsichtbaren
Stoesse im Schnitt. */
const WABE_ZEIT = {
welle: 0.46, // wie weit die Staffelung der Zellen nach aussen reicht
zelle: 0.26, // Fenster einer einzelnen Zelle
nach: 0.14, // Vorsprung einer Zelle vor ihrem Strang
strang: 0.34, // Fenster eines Strangs
};
function visWabenfeld(j) {
const boxen = [];
wabenMarkenBoxen.set(j, boxen);
const mitte = wabeZellen[0];
/* Nur die belegten Waben, kein blasses Feld dahinter. Die Nabe ist nicht
dabei: die kommt zuletzt und eine Spur groesser. */
const aktive = wabeZellen.filter(z => z.nr !== 1 && wabeAktiv(z.nr));
/* Der Abstand zur Nabe steuert die ganze Staffelung. Auf der Website wird
activeRevealNodes nach genau diesem Abstand sortiert, die Nabe steht mit
0 vorn. Weil unsere Belegung nur fuenf verschiedene Abstaende kennt,
sind es fuenf saubere Wellen von innen nach aussen und nicht achtzehn
einzelne Ereignisse -- das liest sich als eine Bewegung. */
const abstand = z => Math.hypot(z.x - mitte.x, z.y - mitte.y);
const weiteste = Math.max(...aktive.map(abstand)) || 1;
const welle = z => WABE_ZEIT.welle * abstand(z) / weiteste;
const n3 = v => v.toFixed(3);
/* Ein Strang als Element. Er behaelt die Endpunkte, mit denen er
gezeichnet wurde, und merkt sich ueber data-mv, welches der beiden
waehrend der Bewegung wandert: immer das aeussere. Damit laeuft die
Streckung in dieselbe Richtung wie die Welle, nach aussen -- und
rueckwaerts zieht sich das Feld in die Nabe zurueck, statt von aussen
her zu zerfallen. Der Verzug haengt an der Zelle, auf die der Strang
zulaeuft: er kommt an, kurz nachdem sie steht. */
const strangEl = (a, b, mv, ziel) =>
'<path data-a="strang" data-v="' + n3(welle(ziel) + WABE_ZEIT.nach) +
'" data-d="' + WABE_ZEIT.strang + '" data-mv="' + mv +
'" data-ax="' + n3(a.x) + '" data-ay="' + n3(a.y) +
'" data-bx="' + n3(b.x) + '" data-by="' + n3(b.y) +
'" d="' + strangPfad(a, b) + '" fill="' + GELB + '" fill-opacity="0.7"/>';
let straenge = "";
/* Straenge von der Nabe zu ihren aktiven Nachbarn. Gezeichnet wird von der
Wabe zur Nabe, das aeussere Ende ist also a. */
wabeZellen.filter(z => z.ring === 1 && wabeAktiv(z.nr)).forEach(z => {
straenge += strangEl(
wabeZu(z, mitte, WABE_INNEN - STRANG_TIEF),
wabeZu(mitte, z, WABE_R * WABE_MITTE * 0.8660254 - STRANG_TIEF), "a", z);
});
/* Und zwischen je zwei aktiven Waben, die aneinandergrenzen. Erst das
macht aus den Einzelkacheln ein Netz: die Programme haengen nicht nur
an der Mitte, sondern auch aneinander. Welches Ende wandert, entscheidet
hier der Abstand zur Nabe. */
aktive.forEach((a, i) => aktive.slice(i + 1).forEach(b => {
const dq = b.q - a.q, dr = b.r - a.r;
if (!WABE_RICHTUNG.some(([q, r]) => q === dq && r === dr)) return;
const bAussen = abstand(b) >= abstand(a);
straenge += strangEl(
wabeZu(a, b, WABE_INNEN - STRANG_TIEF),
wabeZu(b, a, WABE_INNEN - STRANG_TIEF),
bAussen ? "b" : "a", bAussen ? b : a);
}));
/* Die Zellen. Wabe und Marke stehen in einer Gruppe und wachsen gemeinsam
auf, wie node.moveEl auf der Website. Getrennt animiert schwaemme das
Logo neben seiner Wabe her, und das sieht man sofort.
Die Marken liegen als <image> mit Datenadresse darin und nicht inline:
die Dateien von Aircall und Google Calendar bringen eigene
<style>-Bloecke mit Klassennamen wie .st0 mit. In einem gemeinsamen SVG
wuerden die sich gegenseitig ueberschreiben, und zwei Marken saehen
falsch aus. Als eigenes Bild bleibt jede Datei ihr eigenes Dokument.
Dass jetzt je Zelle gruppiert wird und nicht mehr erst alle Waben und
danach alle Marken gezeichnet werden, aendert am Bild nichts:
WABE_ZELLE laesst die Fuge offen, zwei Waben ueberlappen sich nie. */
let zellen = "";
aktive.forEach(z => {
let inhalt = '<path d="' + wabePfad(z.x, z.y, WABE_R * WABE_ZELLE) +
'" fill="' + GELB + '"/>';
const m = WABE_MARKEN[z.nr];
const quelle = m && assets[wabeMarkePfad(m)];
if (quelle) {
const kante = (m.art === "feld" ? WABE_MARKE_FELD : WABE_MARKE_DEKO) * m.gr;
const x = z.x - kante / 2, y = z.y - kante / 2;
boxen.push({ name: m.datei, x, y, kante });
// Runde Ecken, wo die Vorlage sie hat: PandaDoc liefert eine harte
// Kachel, auf der Website liegt sie unter einem Radius.
if (m.radius) {
inhalt += '<clipPath id="wabe-clip-' + z.nr + '"><rect x="' + n1(x) +
'" y="' + n1(y) + '" width="' + n1(kante) + '" height="' + n1(kante) +
'" rx="' + n1(kante * m.radius) + '"/></clipPath>';
}
inhalt += '<image href="' + quelle + '" x="' + n1(x) + '" y="' + n1(y) +
'" width="' + n1(kante) + '" height="' + n1(kante) +
'" preserveAspectRatio="xMidYMid meet"' +
(m.radius ? ' clip-path="url(#wabe-clip-' + z.nr + ')"' : "") + '/>';
}
zellen += '<g data-a="wabe" data-v="' + n3(welle(z)) +
'" data-d="' + WABE_ZEIT.zelle + '" data-cx="' + n1(z.x) +
'" data-cy="' + n1(z.y) + '">' + inhalt + '</g>';
});
/* Die Nabe. Gelbe Wabe, die Marke als Aussparung darin. Auf der Website
ist die Aussparung durchsichtig und zeigt das Feld dahinter; auf dem
weissen Blatt kommt das auf dasselbe heraus, also steht hier Weiss.
Verzug 0: die Marke ist zuerst da, das Feld waechst aus ihr heraus.
Rueckwaerts gelesen heisst das, sie geht als letzte -- alles zieht sich
in sie zurueck. */
const nabe = '<g data-a="wabe" data-v="0" data-d="' + WABE_ZEIT.zelle +
'" data-cx="' + n1(mitte.x) + '" data-cy="' + n1(mitte.y) + '">' +
'<path d="' + wabePfad(mitte.x, mitte.y, WABE_R * WABE_MITTE) +
'" fill="' + GELB + '"/>' +
gMarke(mitte.x, mitte.y, WABE_R * 0.98, SLIDE_GRUND) + '</g>';
/* Der Ausschnitt wird gerechnet, nicht gesetzt: genau um die belegten
Waben herum, damit kein leerer Rand stehenbleibt. Eine Wabe ist 2*rr
breit und 2*rr*0,866 hoch (flat-top, rules.md 3), die Nabe steht mit
WABE_MITTE eine Spur groesser -- sie kann den Kasten mitbestimmen, wenn
sie einmal allein steht.
Kein Beschnitt und keine Maske mehr. Beides gehoerte zum Feld; ohne
Feld wuerde der Beschnitt nichts abschneiden und die Maske nur die
aeusseren Marken ausblassen.
Die ids der Marken-Beschnitte bleiben feste Namen und kommen bei
mehreren Zeichnungen doppelt vor. Das stoert nicht: alle sind gleich
aufgebaut, und url(#..) nimmt die erste. */
const rr = WABE_R * WABE_ZELLE, hh = rr * 0.8660254;
const nr = WABE_R * WABE_MITTE, nh = nr * 0.8660254;
const xs = aktive.map(z => z.x), ys = aktive.map(z => z.y);
const x1 = Math.min(mitte.x - nr, ...xs.map(x => x - rr));
const x2 = Math.max(mitte.x + nr, ...xs.map(x => x + rr));
const y1 = Math.min(mitte.y - nh, ...ys.map(y => y - hh));
const y2 = Math.max(mitte.y + nh, ...ys.map(y => y + hh));
const vb = { x: n1(x1), y: n1(y1), w: n1(x2 - x1), h: n1(y2 - y1) };
return '<svg viewBox="' + vb.x + " " + vb.y + " " + vb.w + " " + vb.h +
'" width="' + vb.w + '" height="' + vb.h + '" xmlns="http://www.w3.org/2000/svg">' +
straenge + zellen + nabe + "</svg>";
}
/* Die Marken des Wabenfelds als Datenadressen. In den Post-Seiten macht das
preloadAssets(); hier reicht dieser Lader, weil es nur diese eine Sorte
Bild gibt. Ohne sie zeichnet visWabenfeld() lauter leere gelbe Waben, und
zwar ohne Fehlermeldung -- deshalb wird ein Fehlschlag hier laut. */
const assets = {};
async function markenLaden() {
for (const m of Object.values(WABE_MARKEN)) {
const pfad = wabeMarkePfad(m);
const res = await fetch(pfad, { cache: "no-store" }).catch(() => null);
if (!res || !res.ok) throw new Error("Marke fehlt: " + pfad);
const blob = await res.blob();
assets[pfad] = await new Promise((ok, weg) => {
const fr = new FileReader();
fr.onload = () => ok(fr.result);
fr.onerror = weg;
fr.readAsDataURL(blob);
});
}
}
/* ─── Zeitverlauf ──────────────────────────────────────────────────────────
Jede Komponente kennt genau eine Bewegung: von 0 (nicht da) bis 1
(steht). Die Sequenz "aus" ist dieselbe rueckwaerts, deshalb gibt es hier
keine zweite Kurve. Ein eigener Ausgang saehe eine Spur besser aus und
kostete die doppelte Zahl an Bildern -- bei einer Einblendung, die eine
halbe Sekunde dauert, sieht das niemand.
Elemente melden ihre Bewegung ueber data-a an, ihren Verzug ueber data-v.
Der Verzug ist ein Anteil des Verlaufs, kein Zeitwert: so bleibt die
Staffelung gleich, egal mit wie vielen Bildern gerendert wird. */
const ruhig = p => 1 - Math.pow(1 - p, 3); // easeOutCubic
const ruhig2 = p => 1 - Math.pow(1 - p, 2); // easeOutQuad, das power2.out der Website
/* back.out(1.75) aus der introTimeline von HoneycombSection.tsx. Die Zelle
schiesst um gut zehn Prozent ueber und faellt zurueck -- das ist der
Unterschied zwischen "wird eingeblendet" und "kommt an". */
const zurueck = p => { const q = p - 1; return q * q * (2.75 * q + 1.75) + 1; };
function bewegen(wurzel, t) {
wurzel.querySelectorAll("[data-a]").forEach(el => {
const v = parseFloat(el.dataset.v || "0");
/* Ohne data-d frisst der Verzug vom Verlauf und der Rest bleibt fuer die
Bewegung. Das genuegt, solange zwei oder drei Elemente versetzt
hereinkommen, und dabei bleibt es fuer die einfachen Komponenten.
Mit data-d bekommt das Element statt dessen ein eigenes Fenster fester
Laenge. Ohne das laesst sich keine lange Staffelung bauen: das Feld hat
achtzehn Zellen, und die letzte haette nach der alten Rechnung ein
Zehntel der Zeit fuer dieselbe Bewegung wie die erste. Sie schnappt
dann herein, waehrend die erste hereinschwebt. */
const d = parseFloat(el.dataset.d || "0") || (1 - v);
const roh = Math.max(0, Math.min(1, (t - v) / Math.max(0.001, d)));
const p = ruhig(roh);
const art = el.dataset.a;
if (art === "auf") {
el.style.opacity = p;
el.style.transform = "translateY(" + n1((1 - p) * 26) + "px)";
} else if (art === "wisch") {
/* Von links aufziehen. clip-path und nicht width: die Schrift darin
soll stehenbleiben und nicht mitgestaucht werden.
Am Ende faellt der Beschnitt ganz weg. "inset(0 0 0 0)" ist nicht
dasselbe wie kein Beschnitt: es schneidet exakt auf die Box, und der
weiche Schatten liegt ausserhalb davon. Das gehaltene Bild hatte
damit eine harte Kante statt eines Auslaufs -- am gerenderten PNG
nachgemessen, nicht vermutet. */
el.style.clipPath = p >= 1 ? "none" : "inset(0 " + n1((1 - p) * 100) + "% 0 0)";
el.style.opacity = p > 0 ? 1 : 0;
} else if (art === "pop") {
el.style.opacity = p;
el.style.transform = "scale(" + n1(0.9 + p * 0.1) + ")";
} else if (art === "wabe") {
/* Eine Zelle des Wabenfelds: waechst aus fast nichts auf ihre Groesse
und schiesst dabei eine Spur ueber. scale 0.16 und back.out(1.75)
sind die Werte der Website, nicht geschaetzt.
Gedreht wird um die eigene Mitte, die als data-cx/cy danebensteht.
Der CSS-Weg waere "transform-box: fill-box" -- das Attribut ist der
Weg, bei dem man nicht nachsehen muss, was der Renderer daraus macht,
und wir kennen den Mittelpunkt ohnehin.
Am Ende fallen beide Attribute wieder weg. "scale(1)" ist zwar
dasselbe wie kein Transform, aber nur rechnerisch: das gehaltene Bild
soll Zeichen fuer Zeichen dasselbe Markup sein wie das Standbild,
sonst rutscht der Stoss im Schnitt doch noch um ein Haar. */
if (roh >= 1) { el.removeAttribute("transform"); el.removeAttribute("opacity"); return; }
const s = 0.16 + 0.84 * zurueck(roh);
const cx = parseFloat(el.dataset.cx), cy = parseFloat(el.dataset.cy);
el.setAttribute("transform", "translate(" + n1(cx - cx * s) + " " + n1(cy - cy * s) +
") scale(" + s.toFixed(4) + ")");
// Die Deckung laeuft ueber dieselbe Kurve und wird gekappt, wie autoAlpha
// in GSAP: sie steht bei 36 Prozent des Fensters, die Bewegung laeuft weiter.
el.setAttribute("opacity", Math.max(0, Math.min(1, zurueck(roh))).toFixed(3));
} else if (art === "strang") {
/* Ein Strang waechst aus seinem inneren Ende heraus. Auf der Website
wandert dafuer der Endpunkt auf seine Ruhelage und redrawStrands()
zeichnet die Form je Bild neu -- genau das passiert hier auch.
Ein clip-path taete es nicht: die Form ist am Fuss anders dick als am
Kopf und laeuft schraeg, ein Rechteck davor wuerde sie quer
abschneiden statt sie wachsen zu lassen.
Bei vollem Verlauf wird der Pfad nicht neu gerechnet, sondern der
gelieferte stehen gelassen. Sonst entschiede die Rundung der
data-Werte ueber das gehaltene Bild. */
if (roh >= 1) { el.setAttribute("fill-opacity", "0.7"); return; }
const g = ruhig2(roh);
const ax = parseFloat(el.dataset.ax), ay = parseFloat(el.dataset.ay);
const bx = parseFloat(el.dataset.bx), by = parseFloat(el.dataset.by);
let A = { x: ax, y: ay }, B = { x: bx, y: by };
if (el.dataset.mv === "b") B = { x: ax + (bx - ax) * g, y: ay + (by - ay) * g };
else A = { x: bx + (ax - bx) * g, y: by + (ay - by) * g };
el.setAttribute("d", strangPfad(A, B));
/* Die Deckung ist frueher fertig als die Streckung (0,42 zu 0,58 auf der
Website). Der Strang steht damit schon, waehrend er die letzte Strecke
noch zuruecklegt, statt am Ende aufzuleuchten. */
el.setAttribute("fill-opacity", (0.7 * Math.min(1, roh / 0.72)).toFixed(3));
}
});
}
/* ─── Bausteine ────────────────────────────────────────────────────────────
Kleine Helfer, damit die Komponenten darunter lesbar bleiben. */
const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
/* *Wort* setzt den Akzent, wie in den Post-Seiten. Genau eine Auszeichnung
pro Zeile -- zwei waren dort schon zweimal zu viel. */
const akzent = s => esc(s).replace(/\*([^*]+)\*/, '<span class="a">$1</span>');
const gMarkeHtml = farbe =>
'<svg viewBox="0 0 384.5 332.9" xmlns="http://www.w3.org/2000/svg">' +
'<polygon fill="' + farbe + '" points="' + G_PUNKTE + '"/></svg>';
const ikonHtml = (name, farbe) =>
'<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
feather(name, 12, 12, 24, farbe || TEAL, 2) + "</svg>";
/* ─── Die Komponenten ──────────────────────────────────────────────────────
Jede kennt ihren Schluessel, eine Buehnengroesse und eine Bauanweisung.
Die Buehne ist absichtlich groesser als noetig: bauen.mjs misst nach dem
Rendern nach und schneidet auf das, was tatsaechlich dasteht. So muss hier
niemand Pixel zaehlen, und ein laengerer Name sprengt nichts.
"bilder" ist die Zahl der Einzelbilder fuer die Einblendung. Kleine
Komponenten bekommen mehr, die grossen Zeichnungen weniger -- bei 1800 px
Breite kostet jedes Bild ein Vielfaches, und eine Zeichnung, die als Ganzes
aufblendet, braucht keine feine Staffelung.
Wenig Text ist Absicht. Noah spricht das Skript, die Einblendung setzt das
Stichwort. Wer mehr braucht, haengt es an die Adresse: die Felder unten
sind ueber ?feld=Wert austauschbar. */
const KOMPONENTEN = [
/* ── Bauchbinden ───────────────────────────────────────────────────────── */
{
key: "bauchbinde-noah", name: "Bauchbinde · Noah", w: 900, h: 340, bilder: 20,
felder: { name: "Noah Geerkens", rolle: "Gründer" },
bau: f =>
'<div class="schatten" data-a="wisch" style="position:absolute;left:56px;top:56px">' +
'<div class="karte bb" style="position:relative">' +
'<span class="bb-balken"></span>' +
'<span class="marke" data-a="auf" data-v=".30">' + gMarkeHtml(GELB) + '</span>' +
'<span class="zeilen">' +
'<b data-a="auf" data-v=".40">' + esc(f.name) + '</b>' +
'<small data-a="auf" data-v=".52">' + esc(f.rolle) + '</small>' +
'</span>' +
'</div>' +
'</div>',
},
/* Die Bauchbinde des Betriebs stand hier als zweite und liess sich ueber
?name= und ?rolle= ohnehin aus der ersten erzeugen. Der Stempel "Vierte
Generation" ist mit ihr weggefallen. */
/* ── Satzkarten ────────────────────────────────────────────────────────── */
{
key: "satz-erp-bleibt", name: "Satz · Dein ERP bleibt", w: 1100, h: 340, bilder: 18,
felder: { zeile: "Dein ERP *bleibt*.", sub: "" },
bau: f => satzKarte(f),
},
{
key: "satz-kein-ersatz", name: "Satz · Kein Ersatz", w: 1100, h: 400, bilder: 18,
felder: { zeile: "Kein *Ersatz*.", sub: "Der Aufbau drumherum." },
bau: f => satzKarte(f),
},
{
key: "satz-miteinander-reden", name: "Satz · Miteinander reden", w: 1300, h: 400, bilder: 18,
felder: { zeile: "Die Tools reden *miteinander*.", sub: "" },
bau: f => satzKarte(f),
},
{
key: "satz-handwerksalltag", name: "Satz · Handwerksalltag", w: 1200, h: 400, bilder: 18,
felder: { zeile: "Kein *Startup*-Projekt.", sub: "Entstanden im Handwerksalltag." },
bau: f => satzKarte(f),
},
/* ── Aufzaehlungen ─────────────────────────────────────────────────────── */
{
key: "stapel-haengt-drin", name: "Stapel · Was drin haengt", w: 800, h: 500, bilder: 22,
felder: { a: "Stammdaten", b: "Aufträge", c: "Buchhaltung" },
bau: f => stapel([
{ ikon: "table", text: f.a },
{ ikon: "file-text", text: f.b },
{ ikon: "folder", text: f.c },
], false),
},
{
key: "stapel-was-fehlte", name: "Stapel · Was gefehlt hat", w: 800, h: 500, bilder: 22,
felder: { a: "KI-Lösungen", b: "CRM", c: "Automatisierungen" },
bau: f => stapel([
{ ikon: "sparkles", text: f.a },
{ ikon: "users", text: f.b },
{ ikon: "zap", text: f.c },
], true),
},
/* ── Zeichnungen ───────────────────────────────────────────────────────── */
{
/* Der Ring aus post2-alt.html, Slide 5. Er sagt in einem Bild, was der
Absatz sagt: die Mitte bleibt, HiveGrid liegt darum. */
key: "ring-kern", name: "Ring · Dein Programm bleibt", w: 1100, h: 840, bilder: 12,
felder: {},
bau: () =>
'<div class="zeichnung" data-a="pop" style="position:absolute;left:56px;top:56px;width:988px">' +
visVerbinden() + '</div>',
},
{
/* Das Wabenfeld von der Startseite, wie in post3.html. Fuer den Absatz
ueber die Tools, die endlich miteinander reden.
Die einzige Komponente, die sich selbst aufbaut: die Nabe steht, dann
waechst das Feld in fuenf Wellen nach aussen, und die Straenge ziehen
hinterher. Der Grund steht bei WABE_ZEIT -- kurz: die anderen
Einblendungen setzen ein Stichwort, waehrend Noah spricht, und duerfen
deshalb nichts wollen. Diese hier ist das Bild zur Aussage und traegt
eine eigene Bewegung.
Deshalb auch die 50 Bilder statt der 12 von vorher. Die Regel oben,
dass grosse Zeichnungen weniger bekommen, gilt fuer die, die als Ganzes
aufblenden -- hier laeuft eine Staffelung ueber achtzehn Zellen und
dreiundzwanzig Straenge, und die braucht Bilder. Bei 25 fps sind das
zwei Sekunden herein und 1,2 hinaus.
Kein data-a am Rahmen: das Feld blendet nicht zusaetzlich als Ganzes
auf. Zwei Bewegungen uebereinander waeren eine zu viel. */
key: "wabenfeld", name: "Wabenfeld · Alles verbunden", w: 1100, h: 940, bilder: 50,
felder: {},
bau: () =>
'<div class="zeichnung" style="position:absolute;left:56px;top:56px;width:980px">' +
visWabenfeld("video") + '</div>',
},
/* ── Marke ─────────────────────────────────────────────────────────────── */
{
key: "logo-bug", name: "Logo · Ecke", w: 700, h: 220, bilder: 14,
felder: { text: "Vom Handwerker für Handwerker" },
bau: f =>
'<div class="schatten" data-a="auf" style="position:absolute;left:56px;top:56px">' +
'<div class="karte bug">' +
'<img src="../assets/brand/logo.svg" alt="HiveGrid">' +
'<span class="strich"></span>' +
'<span>' + esc(f.text) + '</span>' +
'</div>' +
'</div>',
},
/* Hier stand eine Endkarte mit der Tagline und dem Aufruf zur Warteliste.
Ein Schlussbild gehoert in den Schnitt und nicht als Einblendung darauf --
wer eines braucht, baut es als eigene Slide. */
];
/* Zwei Bauanweisungen, die sich mehrere Komponenten teilen. Sie stehen
ausserhalb der Liste, damit dort nur der Inhalt steht und nicht dreimal
dasselbe Markup. */
function satzKarte(f) {
return '<div class="schatten" data-a="wisch" style="position:absolute;left:56px;top:56px">' +
'<div class="karte satz">' +
'<p data-a="auf" data-v=".30">' + akzent(f.zeile) + '</p>' +
(f.sub ? '<p class="sub" data-a="auf" data-v=".48">' + esc(f.sub) + '</p>' : "") +
'</div></div>';
}
/* Die Zeilen laufen versetzt ein, eine nach der anderen. Der Verzug ist ein
Anteil des Verlaufs und keine Zeit: mit mehr Bildern wird die Bewegung
feiner, die Staffelung bleibt dieselbe. */
function stapel(zeilen, an) {
return '<div class="schatten" style="position:absolute;left:56px;top:56px">' +
zeilen.map((z, i) =>
'<div class="karte zeile' + (an ? " an" : "") + '" data-a="auf" data-v="' +
(0.12 + i * 0.2).toFixed(2) + '">' +
'<span class="ikon">' + ikonHtml(z.ikon, an ? GELB : TEAL) + '</span>' +
'<span>' + esc(z.text) + '</span>' +
'</div>').join("") +
'</div>';
}
/* ─── Aufbau ───────────────────────────────────────────────────────────────
Ohne k steht der Kontaktbogen, mit k genau eine Komponente. bauen.mjs liest
KOMP_LISTE aus, wartet auf KOMP_BEREIT und faehrt sie ab. */
window.KOMP_LISTE = KOMPONENTEN.map(k => ({
key: k.key, name: k.name, w: k.w, h: k.h, bilder: k.bilder,
}));
const buehne = document.getElementById("buehne");
const q = new URLSearchParams(location.search);
function eine(k, felder) {
const el = document.createElement("div");
/* Der Rahmen, auf den sich die Komponenten mit position:absolute beziehen.
Er traegt die volle Buehnengroesse und ist selbst unsichtbar -- deshalb
ist er beim Messen ausgenommen, sonst waere jeder Kasten so gross wie die
Buehne und das PNG bestuende zur Haelfte aus Nichts. */
el.dataset.buehne = "1";
el.style.cssText = "position:relative;width:" + k.w + "px;height:" + k.h + "px";
el.innerHTML = k.bau(Object.assign({}, k.felder, felder));
return el;
}
/* Wie viel Luft um das Gemessene bleibt. Zwei Dinge stehen ausserhalb des
Rechtecks, das getBoundingClientRect() liefert: der weiche Schatten der
Karte (10 px Versatz, 34 px Unschaerfe) und die Bewegung selbst -- wer
aufblendet, startet 26 px tiefer. Ohne die Zugabe schneidet das PNG beides
ab, und im Schnitt sieht man eine harte Kante, wo der Schatten aufhoert. */
const ZUGABE = 52;
/* Der Ausschnitt, den bauen.mjs aufnimmt: alles, was tatsaechlich dasteht,
plus Zugabe. Gemessen wird im gehaltenen Zustand und einmal -- ein Kasten,
der je Bild wandert, ergaebe eine zappelnde Sequenz. */
window.KOMP_KASTEN = () => {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
buehne.querySelectorAll("*").forEach(el => {
if (el.dataset.buehne) return;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return;
x1 = Math.min(x1, r.left); y1 = Math.min(y1, r.top);
x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom);
});
if (!isFinite(x1)) throw new Error("nichts zu messen in " + window.KOMP_AKTUELL);
const x = Math.max(0, Math.floor(x1 - ZUGABE));
const y = Math.max(0, Math.floor(y1 - ZUGABE));
return { x, y, w: Math.ceil(x2 + ZUGABE) - x, h: Math.ceil(y2 + ZUGABE) - y };
};
/* Die Schnitte, die auf den Karten vorkommen. Sie werden ausdruecklich
angefordert, und genau darauf kommt es an:
"document.fonts.ready" wartet nur auf Ladevorgaenge, die bereits laufen.
Solange keine Karte im DOM steht, braucht die Seite keine Manrope, es laeuft
also nichts, und das Versprechen loest sofort auf -- erfuellt, ohne dass ein
einziges Byte Schrift da waere. Danach wurde die Karte gebaut, Chrome fing
an zu laden, und ob es rechtzeitig fertig war, entschied der Zufall.
Das ist keine Theorie: dieselbe Komponente hintereinander gemessen kam
abwechselnd 984 und 1010 px breit heraus. Ein Teil der Dateien im Ordner ist
damit in der Ersatzschrift gerendert -- genau die Falle aus rules.md 4, nur
eine Ebene frueher als dort beschrieben.
document.fonts.load() holt eine Schriftlage dagegen unabhaengig davon, ob
gerade jemand sie benutzt. Erst danach ist "ready" eine Aussage. */
const SCHNITTE = ["400", "500", "600", "700", "800"];
async function schriftLaden() {
await Promise.all(SCHNITTE.map(s => document.fonts.load(s + " 52px Manrope")));
await document.fonts.ready;
if (!document.fonts.check("800 52px Manrope")) throw new Error("Manrope fehlt");
}
/* Dieselbe Falle eine Etage tiefer: die Wortmarke in logo-bug ist ein <img>
mit "width:auto", ihre Breite haengt also am geladenen Bild und bestimmt die
Breite der Karte mit. Ist es noch nicht da, misst KOMP_KASTEN() eine Karte
ohne Logo. Die Marken des Wabenfelds sind davon nicht betroffen, die liegen
als Datenadresse vor und haben feste Masse. */
async function bilderFertig() {
await Promise.all([...document.images].map(
b => b.complete ? null : b.decode().catch(() => null)));
}
/* Erst die Marken, dann die Schrift, dann bauen. Beides muss stehen, bevor
das erste Bild faellt: eine fehlende Marke bliebe eine leere gelbe Wabe,
eine fehlende Schrift rendert still in der Ersatzschrift und faellt erst
in der fertigen Datei auf (rules.md 4 und 7). */
window.KOMP_BEREIT = (async () => {
await markenLaden();
await schriftLaden();
if (q.has("alle")) {
document.body.className = "bogen";
KOMPONENTEN.forEach(k => {
const zelle = document.createElement("div");
zelle.className = "bogen-zelle";
zelle.innerHTML = '<span class="bez">' + esc(k.key) + " · " + k.w + "×" + k.h + "</span>";
zelle.appendChild(eine(k));
buehne.appendChild(zelle);
});
bewegen(buehne, 1);
return true;
}
const key = q.get("k") || KOMPONENTEN[0].key;
const k = KOMPONENTEN.find(x => x.key === key);
if (!k) throw new Error("Komponente unbekannt: " + key);
/* Freie Felder aus der Adresse, damit sich Texte ohne Codeaenderung
austauschen lassen: ?k=satz-erp-bleibt&zeile=Dein *CRM* bleibt. */
const felder = {};
for (const [n, w] of q) if (n !== "k" && n !== "t") felder[n] = w;
buehne.style.width = k.w + "px";
buehne.style.height = k.h + "px";
buehne.appendChild(eine(k, felder));
await bilderFertig();
window.KOMP_AKTUELL = k.key;
window.setT = t => bewegen(buehne, t);
setT(q.has("t") ? parseFloat(q.get("t")) : 1);
return true;
})();
</script>