- Format-Schalter 4:5 / 1:1 in Post 9, Stand auf 1:1 (Meta nimmt ein Carousel mit Video als Anzeige nur quadratisch), Video in 1:1 - animation.mjs baut das Format der Seite, Datei mit -1x1 - Variante 2/3: Kundengeschichte nach dem weclapp-Karussell, hell und dunkel - Variante 4/5: Zahlen-Karussell nach HERO Insights mit KfW- und Bitkom-Zahlen, Noah als Teaser, hell und dunkel - post9-varianten.html zeigt alle Varianten untereinander - Menüs und Übersicht um die Varianten ergänzt, Regel zu 1:1 in rules.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5377 lines
245 KiB
HTML
5377 lines
245 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<script>
|
||
/* Uebersichts-Modus: die Seite laeuft dann als Kachel in uebersicht.html.
|
||
Das Attribut muss vor dem ersten Zeichnen stehen, sonst blitzt die
|
||
Bedienoberflaeche kurz auf. Deshalb hier oben und nicht im Hauptskript. */
|
||
if (location.search.indexOf("nur=streifen") >= 0)
|
||
document.documentElement.dataset.nur = "streifen";
|
||
/* Rendermodus fuer den Videobau: die Seite zeigt dann allein das Blatt der
|
||
Animation, in natuerlicher Groesse an 0,0. animation.mjs stellt das Fenster
|
||
auf 1080 x 1350 und nimmt es ohne Ausschnittsrechnung auf. */
|
||
if (location.search.indexOf("nur=anim") >= 0)
|
||
document.documentElement.dataset.nur = "anim";
|
||
</script>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>PostGrid Post 2</title>
|
||
<link rel="stylesheet" href="./assets/fonts/manrope.css">
|
||
<style>
|
||
/* ─── HiveGrid design tokens (mirrored from app/globals.css) ─────────────
|
||
Plain hex on purpose — the export libraries choke on oklch()/Tailwind.
|
||
--dark-bg #30313D is the colour of the dark story section on the site; it
|
||
carries the whole shape layer here. */
|
||
:root {
|
||
--accent-yellow: #F1B729;
|
||
--accent-teal: #0D6E6E;
|
||
--surface: #FFFFFF;
|
||
--bg-primary: #FAFAFA;
|
||
--dark-bg: #30313D;
|
||
--accent-yellow-light: #FFE082;
|
||
--accent-yellow-dark: #3E2C0A;
|
||
--text-primary: #1A1A1A;
|
||
--text-secondary:#555555;
|
||
--text-on-dark: #C8D0DC;
|
||
--text-white: #FFFFFF;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
html, body { overscroll-behavior: none; }
|
||
body {
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
background: #17181F; color: #E8EAEE;
|
||
height: 100vh; overflow: hidden;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
|
||
/* ─── Menueleiste ────────────────────────────────────────────────────────
|
||
Statt einer Knopfreihe fuer die Projekte eine Leiste wie in einem
|
||
Bildprogramm. Sie bedient keine eigene Logik, sondern klickt die
|
||
vorhandenen Schalter im Bedienfeld an: sonst gaebe es zwei Wahrheiten
|
||
ueber denselben Zustand. */
|
||
.menubar {
|
||
flex: none; display: flex; align-items: stretch; gap: 2px;
|
||
height: 34px; padding: 0 8px;
|
||
background: #14161C; border-bottom: 1px solid #24262F;
|
||
font-size: 13px; user-select: none; position: relative; z-index: 60;
|
||
}
|
||
.menubar .marke {
|
||
display: flex; align-items: center; padding: 0 12px 0 4px;
|
||
font-weight: 800; letter-spacing: -0.01em; color: #E8EAEE;
|
||
}
|
||
.menu { position: relative; }
|
||
.menu > button {
|
||
height: 100%; padding: 0 11px; cursor: pointer;
|
||
background: none; border: 0; color: #C7CCD6;
|
||
font-family: inherit; font-size: 13px;
|
||
}
|
||
.menu > button:hover { background: #1D1F26; }
|
||
.menu[data-offen="ja"] > button { background: #262932; color: #fff; }
|
||
.menu > .liste { display: none; }
|
||
.menu[data-offen="ja"] > .liste {
|
||
display: block; position: absolute; top: 100%; left: 0;
|
||
min-width: 232px; padding: 5px;
|
||
background: #1D1F26; border: 1px solid #2E313B; border-radius: 9px;
|
||
}
|
||
.menu .liste button {
|
||
display: flex; width: 100%; align-items: center; gap: 14px;
|
||
padding: 6px 9px; cursor: pointer; text-align: left;
|
||
background: none; border: 0; border-radius: 6px;
|
||
color: #C7CCD6; font-family: inherit; font-size: 13px;
|
||
}
|
||
.menu .liste button:hover { background: #2A2D36; color: #fff; }
|
||
.menu .liste button:disabled { opacity: .38; cursor: default; background: none; }
|
||
.menu .liste button .kurz { margin-left: auto; color: #6E7480; font-size: 11.5px; }
|
||
.menu .liste button .haken { width: 12px; color: var(--accent-yellow); }
|
||
.menu .liste hr { height: 1px; margin: 5px 4px; background: #2E313B; border: 0; }
|
||
|
||
.arbeitsflaeche { flex: 1; display: flex; min-height: 0; }
|
||
|
||
/* Inhaltsfelder der gewaehlten Slide. Eine Spalte, damit lange Texte nicht
|
||
in einer schmalen Zelle kleben. */
|
||
#inhalt { display: flex; flex-direction: column; gap: 4px; }
|
||
.inh-label { font-size: 11px; color: #8A8F9B; letter-spacing: .04em; margin-top: 4px; }
|
||
#inhalt input, #inhalt textarea, #inhalt select {
|
||
width: 100%; box-sizing: border-box;
|
||
background: #22242C; color: #D6DAE3; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 7px 8px;
|
||
font-family: inherit; font-size: 12px; line-height: 1.45;
|
||
}
|
||
#inhalt textarea { resize: vertical; min-height: 46px; }
|
||
#inhalt input:focus, #inhalt textarea:focus, #inhalt select:focus {
|
||
outline: none; border-color: var(--accent-yellow);
|
||
}
|
||
#slide-ops button { flex: none; padding: 8px 10px; }
|
||
|
||
/* Das Feld des gewaehlten Elements. Es steht rechts, damit links die Slide
|
||
und das Allgemeine bleiben und beides nicht durcheinandergeht. Es faehrt
|
||
nur bei einer Auswahl ein; die Buehne rechnet ihre Einpassung danach neu,
|
||
sonst haenge der Streifen schief. */
|
||
.panel-rechts {
|
||
display: none;
|
||
border-right: none; border-left: 1px solid #24262F;
|
||
padding: 22px 22px 40px 22px;
|
||
}
|
||
body[data-elem="an"] .panel-rechts { display: block; }
|
||
.el-kopf { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||
.el-kopf h1 { flex: 1; margin: 0; }
|
||
.el-zu {
|
||
flex: none; width: 26px; height: 26px; border-radius: 7px; cursor: pointer;
|
||
background: #22242C; color: #8A8F9B; border: 1px solid #2E313B;
|
||
font-family: inherit; font-size: 13px; line-height: 1;
|
||
}
|
||
.el-zu:hover { background: #2A2D36; color: #C7CCD6; }
|
||
#el-inhalt textarea {
|
||
width: 100%; box-sizing: border-box; resize: vertical; min-height: 62px;
|
||
background: #22242C; color: #D6DAE3; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 7px 8px;
|
||
font-family: inherit; font-size: 12px; line-height: 1.45;
|
||
}
|
||
#el-inhalt textarea:focus { outline: none; border-color: var(--accent-yellow); }
|
||
#el-inhalt .abschnitt { font-size: 11px; color: #8A8F9B; letter-spacing: .09em;
|
||
text-transform: uppercase; margin: 14px 0 6px; }
|
||
#el-inhalt .opts button { flex: 1; }
|
||
|
||
/* Zielmarke beim Ablegen einer Datei. */
|
||
.holder.ablegen::before {
|
||
content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none;
|
||
outline: 12px dashed var(--accent-yellow); outline-offset: -6px;
|
||
background: rgba(241,183,41,.10);
|
||
}
|
||
|
||
.saved {
|
||
font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s;
|
||
margin-left: 6px;
|
||
}
|
||
.saved.on { opacity: 1; }
|
||
.forget { font-size: 11px; color: #6E7480; background: none; border: 0;
|
||
cursor: pointer; padding: 0; font-family: inherit; }
|
||
.forget:hover { color: #EB7C6B; }
|
||
|
||
/* ─── Control panel ──────────────────────────────────────────────────── */
|
||
/* max-width statt nur width: bei einem schmalen Fenster kaempfen linkes
|
||
Feld, Buehne und rechtes Feld um denselben Platz, und ohne Obergrenze
|
||
schiebt sich das rechte aus dem Fenster. Dann ist es nicht mehr
|
||
zuzumachen, weil sein Knopf mit hinausrutscht.
|
||
overflow statt overflow-y aus demselben Grund: wird das Feld schmal,
|
||
muss sein Inhalt waagerecht erreichbar bleiben. */
|
||
.panel {
|
||
width: 306px; max-width: 42vw; flex: none; height: 100%; overflow: auto;
|
||
padding: 28px 22px 40px 28px;
|
||
border-right: 1px solid #24262F; background: #17181F;
|
||
}
|
||
.panel h1 { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 4px; }
|
||
.panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 22px; line-height: 1.5; }
|
||
.panel a { color: #8A8F9B; }
|
||
|
||
.group { margin-bottom: 20px; }
|
||
.group > label,
|
||
.group > summary {
|
||
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
|
||
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
|
||
}
|
||
/* Zugeklappte Kategorien: der Marker ersetzt den Standard-Pfeil, damit die
|
||
Zeile bündig zur offenen Gruppe bleibt. */
|
||
details.group > summary {
|
||
cursor: pointer; list-style: none; margin-bottom: 0;
|
||
padding: 5px 0; display: flex; align-items: center; gap: 7px;
|
||
transition: color .15s;
|
||
}
|
||
details.group > summary::-webkit-details-marker { display: none; }
|
||
details.group > summary::before {
|
||
content: "▸"; font-size: 10px; color: #4E5360; transition: transform .15s;
|
||
}
|
||
details.group[open] > summary::before { transform: rotate(90deg); }
|
||
details.group > summary:hover { color: #C7CCD6; }
|
||
details.group[open] > summary { color: #8A8F9B; margin-bottom: 9px; }
|
||
details.group { margin-bottom: 4px; }
|
||
details.group[open] { margin-bottom: 20px; }
|
||
|
||
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||
.opts.one { grid-template-columns: 1fr; }
|
||
.opts.three { grid-template-columns: 1fr 1fr 1fr; }
|
||
.opts button {
|
||
font-family: inherit; font-size: 12.5px; font-weight: 600;
|
||
padding: 10px; border-radius: 8px; cursor: pointer; text-align: left;
|
||
background: #22242C; color: #C7CCD6; border: 1px solid #2E313B;
|
||
transition: background .15s, border-color .15s, color .15s;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.opts.three button { justify-content: center; text-align: center; padding: 10px 4px; }
|
||
.opts button:hover { background: #2A2D36; }
|
||
.opts button[aria-pressed="true"] {
|
||
background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow);
|
||
}
|
||
.num {
|
||
width: 18px; height: 18px; border-radius: 4px; flex: none;
|
||
display: grid; place-items: center; font-size: 10.5px; font-weight: 800;
|
||
background: rgba(255,255,255,.09);
|
||
}
|
||
button[aria-pressed="true"] .num { background: rgba(0,0,0,.16); }
|
||
.swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; border: 1px solid rgba(0,0,0,.18); }
|
||
|
||
.row { display: flex; gap: 8px; }
|
||
.row button, .btn {
|
||
font-family: inherit; font-size: 13px; font-weight: 700;
|
||
padding: 12px 13px; border-radius: 8px; cursor: pointer; border: none;
|
||
background: var(--accent-yellow); color: #1A1A1A; flex: 1; transition: filter .15s;
|
||
}
|
||
.row button:hover, .btn:hover { filter: brightness(1.07); }
|
||
.btn.secondary { background: #22242C; color: #C7CCD6; border: 1px solid #2E313B; }
|
||
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
||
|
||
.keys { font-size: 12px; color: #6E7480; line-height: 1.85; }
|
||
.keys kbd {
|
||
background: #22242C; border: 1px solid #343843; border-bottom-width: 2px;
|
||
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
|
||
font-size: 11px; color: #C7CCD6;
|
||
}
|
||
#el-inhalt { font-size: 12px; line-height: 1.55; color: #C7CCD6; }
|
||
#el-inhalt .dim { color: #6E7480; }
|
||
/* Ziehen ist gut fuer grob, tippen fuer genau. Ohne Eingabefelder laesst
|
||
sich ein Wert nicht ueber mehrere Slides hinweg exakt gleich setzen. */
|
||
#el-inhalt .fields {
|
||
display: grid; grid-template-columns: 42px 1fr 20px 1fr;
|
||
gap: 6px 7px; align-items: center;
|
||
}
|
||
#el-inhalt label { color: #6E7480; font-size: 11px; }
|
||
#el-inhalt input {
|
||
width: 100%; box-sizing: border-box;
|
||
background: #14161C; border: 1px solid #343845; border-radius: 6px;
|
||
color: #E7EAF0; font: inherit; font-variant-numeric: tabular-nums;
|
||
padding: 4px 6px; text-align: right; -moz-appearance: textfield;
|
||
}
|
||
#el-inhalt input::-webkit-outer-spin-button,
|
||
#el-inhalt input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||
#el-inhalt input:focus { outline: none; border-color: var(--accent-yellow); }
|
||
#el-inhalt .hint { color: #6E7480; margin-top: 6px; }
|
||
#el-inhalt .row { margin-top: 0; }
|
||
.std { margin-bottom: 22px; }
|
||
.std summary {
|
||
cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .09em;
|
||
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
|
||
}
|
||
.std table { width: 100%; border-collapse: collapse; font-size: 11.5px; color: #8A8F9B; }
|
||
.std td { padding: 3px 0; }
|
||
.std td:last-child { text-align: right; color: #C7CCD6; font-variant-numeric: tabular-nums; }
|
||
.std h4 { font-size: 11px; color: #6E7480; margin: 10px 0 4px; font-weight: 700; }
|
||
|
||
.status { margin-top: 12px; font-size: 12.5px; color: #6E7480; min-height: 18px; }
|
||
.status.ok { color: #6FCF97; }
|
||
.status.err { color: #EB7C6B; }
|
||
|
||
/* ─── Stage ──────────────────────────────────────────────────────────── */
|
||
/* ─── Arbeitsfläche ───────────────────────────────────────────────────────
|
||
Klar abgesetzt von der Sidebar: eigener Grund, eigener Scroll, eigener
|
||
Zoom. Was hier passiert, lässt die Bedienleiste unberührt. */
|
||
.stage {
|
||
/* Nicht 100vh: darueber sitzt die Menueleiste. Mit voller
|
||
Fensterhoehe rutscht die Fussleiste samt Zoom und Merkzettel
|
||
unter den unteren Rand. */
|
||
flex: 1; position: relative; height: 100%; overflow: hidden;
|
||
background-color: #101116;
|
||
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
|
||
background-size: 24px 24px;
|
||
}
|
||
/* overscroll-behavior contain hält die Wisch-Geste des Browsers zurück:
|
||
Ohne das springt horizontales Trackpad-Scrollen am linken Rand auf den
|
||
vorigen Tab, statt im Canvas zu bleiben. */
|
||
.scroller {
|
||
position: absolute; inset: 0; overflow: auto;
|
||
overscroll-behavior: contain;
|
||
}
|
||
.scroller.panning { cursor: grabbing; }
|
||
.scroller.panready { cursor: grab; }
|
||
/* width/height: max-content ist hier entscheidend. Mit reinem
|
||
"justify-content: center" ragt breiter Inhalt über beide Seiten hinaus,
|
||
aber scrollLeft kann nicht negativ werden: Der linke Teil wäre dann
|
||
unerreichbar. So wächst die Fläche mit dem Inhalt, das Zentrieren greift
|
||
nur noch, solange etwas Platz übrig ist. */
|
||
.canvas {
|
||
width: max-content; min-width: 100%;
|
||
height: max-content; min-height: 100%;
|
||
box-sizing: border-box; padding: 60px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.hud {
|
||
position: absolute; left: 0; right: 0; bottom: 0;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: 16px; padding: 14px 20px; pointer-events: none;
|
||
background: linear-gradient(transparent, rgba(16,17,22,.92) 55%);
|
||
}
|
||
.hud > * { pointer-events: auto; }
|
||
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
|
||
|
||
.zoom { display: flex; align-items: center; gap: 4px; }
|
||
.zoom button {
|
||
font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
|
||
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 6px 10px; min-width: 32px;
|
||
}
|
||
.zoom button:hover { background: #262932; }
|
||
.zoom .level { min-width: 58px; text-align: center; color: #8A8F9B; font-variant-numeric: tabular-nums; }
|
||
/* Merkzettel neben der Zoomleiste: ein Stand, den man von Hand ablegt und
|
||
bewusst zurueckholt. Der laufende Projektstand speichert dauernd von
|
||
selbst und taugt deshalb nicht als Rueckfallpunkt. */
|
||
.snap { display: flex; align-items: center; gap: 4px; margin-right: 10px; }
|
||
.snap button {
|
||
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
|
||
background: #1D1F26; color: #C7CCD6; border: 1px solid #2E313B;
|
||
border-radius: 7px; padding: 6px 11px;
|
||
}
|
||
.snap button:hover { background: #262932; }
|
||
.snap button:disabled { opacity: .4; cursor: default; }
|
||
/* Der Streifen zeigt alle Slides nebeneinander und lückenlos, damit der
|
||
durchgehende Aufbau beim Arbeiten sichtbar ist. Der Rahmen reserviert die
|
||
skalierte Fläche; die Slides selbst bleiben immer echte 1080 x 1350. */
|
||
.strip-wrap { overflow: hidden; flex: none; align-self: flex-start; }
|
||
/* Bei gebrochenem Maßstab (etwa 41 %) ist eine Slide 442,8 px breit. Der
|
||
Browser rundet jede Box einzeln, und zwischen ihnen blitzt für Sekunden-
|
||
bruchteile eines Pixels der dunkle Grund durch. Der Streifen bekommt
|
||
deshalb die Slide-Farbe als Untergrund: Die Fuge zeigt dann dieselbe Farbe
|
||
und verschwindet. Im Export gibt es sie ohnehin nicht, dort wird jede
|
||
Slide einzeln in exakten 1080 x 1350 gerendert. */
|
||
.strip { display: flex; transform-origin: top left; background: #30313D; }
|
||
.holder { position: relative; flex: none; cursor: pointer; }
|
||
/* Auswahlrahmen sitzt auf dem Holder, nicht auf der Slide, damit er nie
|
||
im Export landet. */
|
||
.holder.sel::after {
|
||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||
outline: 10px solid var(--accent-yellow); outline-offset: -5px;
|
||
}
|
||
.strip[data-view="einzeln"] .holder { display: none; }
|
||
.strip[data-view="einzeln"] .holder.sel { display: block; }
|
||
|
||
/* ─── Slide base — exactly Instagram 4:5 (1080 × 1350) ───────────────── */
|
||
.slide {
|
||
width: 1080px; height: 1350px;
|
||
position: relative; overflow: hidden;
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
}
|
||
|
||
/* ─── Grund ──────────────────────────────────────────────────────────────
|
||
Ein einziger Grund: das Raster. Formen, Waben, Punkte und die grosse
|
||
G-Marke sind raus, ebenso das durchgehende Band mit den Sechsecken auf
|
||
den Naehten. Einstellbar bleibt nur die Staerke. */
|
||
.bg { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||
.slide[data-int="dezent"] .bg { --int-mul: .55; }
|
||
.slide[data-int="kraeftig"] .bg { --int-mul: 1; }
|
||
.layer-raster { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||
|
||
/* Optische Ausrückung des Tags: Der Kasten steht bündig zur Inhaltskante,
|
||
sein TEXT dadurch um die 20 px Innenabstand eingerückt gegenüber der
|
||
Headline. Das negative Margin holt den Text auf dieselbe Linie. Zum
|
||
Vergleichen umschaltbar. */
|
||
|
||
.layer-raster {
|
||
background-image:
|
||
linear-gradient(var(--pat-color) 2px, transparent 2px),
|
||
linear-gradient(90deg, var(--pat-color) 2px, transparent 2px);
|
||
background-size: 78px 78px;
|
||
opacity: calc(var(--pat-strength) * var(--int-mul, 1));
|
||
}
|
||
|
||
|
||
/* ─── Visualisierungen ───────────────────────────────────────────────────
|
||
Post 2 zeigt keine Screenshots, sondern das Stichwort aus dem Text als
|
||
Bild. Gebaut aus derselben Formensprache wie die Slides: chamferte
|
||
Kaesten, Sechsecke, der eine gelbe Akzent. Alles HTML, damit es beim
|
||
3-fachen Export genauso scharf bleibt wie die Schrift und sich im Canvas
|
||
verschieben laesst wie jedes andere Element.
|
||
Die Maße sind gemessen, nicht geschaetzt: der Inhaltsblock endet bei
|
||
y 415 bis 460, unten bleiben 92 px Rand. Rund 800 px Hoehe stehen also
|
||
zur Verfuegung, und die soll eine Visualisierung auch fuellen. */
|
||
.vis {
|
||
position: absolute; z-index: 1;
|
||
left: var(--vis-left, 92px);
|
||
bottom: var(--vis-bottom, 96px);
|
||
width: var(--vis-w, 896px);
|
||
color: var(--head-color);
|
||
}
|
||
.vis svg { display: block; width: 100%; height: auto; }
|
||
|
||
/* Hier stand das Layout-CSS der alten Visualisierungen. Alle sechs sind
|
||
inzwischen gezeichnet, das SVG fuellt seinen Container selbst. Die Regeln
|
||
haben dreimal zugeschlagen, weil die Klasse weiter am Container haengt:
|
||
.vis-verbinden mit display:grid schob das SVG in eine Rasterzelle,
|
||
.vis-lose mit zwei Spalten auf ein Drittel Breite. Es darf hier kein
|
||
Layout-CSS mehr auftauchen. Gebraucht werden nur .vis und .vis svg.
|
||
|
||
/* Slide 5 ist gezeichnet und braucht kein eigenes Layout mehr: das SVG
|
||
fuellt den Container. Das alte Raster stand hier noch und hat die
|
||
Zeichnung als Rasterzelle auf ein Drittel zusammengeschoben. Wer eine
|
||
Visualisierung auf SVG umstellt, muss ihr Layout-CSS mit entfernen. */
|
||
|
||
/* Slide 6 ist gezeichnet, das alte Formular-Layout ist entfallen. Die
|
||
Klasse .vis-warteliste haengt weiter am Container: Layout-CSS darf hier
|
||
nicht wieder auftauchen, sonst rahmt es das SVG ein. */
|
||
|
||
/* ─── Teamkarte ──────────────────────────────────────────────────────────
|
||
Nachbau der Karte von der Ueber-uns-Seite im aktiven Zustand: Hintergrund
|
||
entsaettigt und abgedunkelt, die Person in voller Farbe, das gelbe
|
||
Namensschild sichtbar. Werte aus TeamCards.tsx uebernommen.
|
||
Ohne die weichgezeichnete Ellipse aus dem Original: die haengt an
|
||
backdrop-filter, und was der Export daraus macht, ist nicht verlaesslich. */
|
||
/* Der Halter traegt das Foto und darueber die Zeichenebene. Die Ebene
|
||
ragt bewusst ueber das Foto hinaus, oben am weitesten: dort sitzt der
|
||
Helm. Ihre Masse sind Prozent und keine Pixel, damit sie mitwaechst,
|
||
wenn die Karte im Canvas breiter gezogen wird. */
|
||
.karte-halter { position: relative; }
|
||
/* Der Selektor muss ".vis svg" schlagen, sonst zieht die allgemeine Regel
|
||
die Ebene auf Containerbreite und der Rahmen sitzt zu klein und
|
||
verschoben. Deshalb hier svg.karte-skizze und nicht nur die Klasse. */
|
||
.vis svg.karte-skizze {
|
||
position: absolute; z-index: 6; pointer-events: none;
|
||
left: -24.19%; width: 153.23%;
|
||
top: -23.23%; height: 141.29%;
|
||
}
|
||
.karte {
|
||
position: relative; aspect-ratio: 4/5; overflow: hidden;
|
||
border-radius: 8px; background: #0f1a2e; isolation: isolate;
|
||
}
|
||
.karte-grund {
|
||
position: absolute; inset: 0; z-index: 1; overflow: hidden;
|
||
border-radius: 8px; background: #2b2d2a;
|
||
filter: grayscale(.04) saturate(.74) brightness(.73);
|
||
}
|
||
.karte-grund img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||
.karte-wash {
|
||
position: absolute; inset: 0;
|
||
background: linear-gradient(180deg, rgba(15,26,46,.01), rgba(15,26,46,.12));
|
||
}
|
||
.karte-person {
|
||
position: absolute; inset: 0; z-index: 4;
|
||
width: 100%; height: 100%; object-fit: cover;
|
||
filter: saturate(1) contrast(1.01);
|
||
}
|
||
.karte-name {
|
||
position: absolute; left: 6%; bottom: 5.7%; z-index: 5;
|
||
display: flex; align-items: baseline; gap: 13px;
|
||
padding: 13px 17px; background: var(--accent-yellow); color: #101010;
|
||
}
|
||
.karte-name span { font-size: 27px; font-weight: 700; letter-spacing: -.03em; line-height: 1.15; }
|
||
.karte-name small { font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||
|
||
/* Die Buehne war bisher eine einzige Flaeche, auf der Scroller und
|
||
Fussleiste uebereinander lagen. Jetzt sind es zwei Zeilen: oben die
|
||
Flaeche mit dem Streifen, darunter die Animation -- im selben Fenster,
|
||
unter dem Canvas. Das Umhaengen macht animLeisteBauen(), so bleibt der
|
||
HTML-Rumpf jeder Datei unveraendert. */
|
||
.stage { display: flex; flex-direction: column; }
|
||
#stageflaeche { position: relative; flex: 1 1 auto; min-height: 0; }
|
||
html[data-nur="streifen"] .stage { display: block; }
|
||
html[data-nur="streifen"] #stageflaeche { position: static; }
|
||
|
||
/* Farben wie der Rest der Oberflaeche: Panelgrund #17181F, Trennlinien
|
||
#24262F, Bedienfelder #1D1F26 mit Rand #2E313B. Hier standen einmal
|
||
Variablennamen, die es in dieser Datei nie gab -- eine ungueltige var()
|
||
ohne Ersatzwert laesst die ganze Angabe fallen, deshalb fehlten Rahmen
|
||
und Grund, ohne dass irgendwo ein Fehler auftauchte. */
|
||
#animleiste {
|
||
flex: 0 0 auto; border-top: 1px solid #24262F;
|
||
background: #14161C; padding: 0 20px;
|
||
max-height: 46vh; overflow-y: auto;
|
||
}
|
||
#animleiste[open] { padding-bottom: 16px; }
|
||
|
||
/* Der Kopf ist der Schalter. Ein eigener Knopf daneben waere eine zweite
|
||
Trefferflaeche fuer dieselbe Sache -- die ganze Zeile ist gross genug,
|
||
und ein <details> bringt Tastaturbedienung von selbst mit. */
|
||
.animkopf {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 12px 0; cursor: pointer; user-select: none;
|
||
list-style: none;
|
||
}
|
||
.animkopf::-webkit-details-marker { display: none; }
|
||
.animkopf:hover b { color: #FFFFFF; }
|
||
.animpfeil {
|
||
flex: none; width: 13px; height: 13px; color: #6E7480;
|
||
transition: transform .16s ease;
|
||
}
|
||
#animleiste[open] .animpfeil { transform: rotate(90deg); }
|
||
.animkopf b {
|
||
font-size: 11px; font-weight: 700; letter-spacing: .09em;
|
||
text-transform: uppercase; color: #E8EAEE;
|
||
}
|
||
.animkopf > span { font-size: 11.5px; color: #6E7480; }
|
||
.animreihe { display: flex; gap: 14px; flex-wrap: wrap; padding-bottom: 2px; }
|
||
.animkarte {
|
||
display: flex; gap: 14px; align-items: stretch;
|
||
border: 1px solid #2E313B; border-radius: 10px;
|
||
padding: 12px; background: #1D1F26;
|
||
}
|
||
|
||
/* Die Vorschau ist das echte Blatt, nur kleiner gestellt. Kein Nachbau:
|
||
was hier laeuft, ist Zeichen fuer Zeichen dasselbe, was animation.mjs
|
||
aufnimmt. --animskala bestimmt, wie gross. */
|
||
.animbuehne {
|
||
--animskala: .215;
|
||
flex: none; position: relative; overflow: hidden; border-radius: 6px;
|
||
width: calc(1080px * var(--animskala));
|
||
height: calc(1350px * var(--animskala));
|
||
box-shadow: 0 0 0 1px #2E313B;
|
||
}
|
||
.animbuehne > .slide {
|
||
position: absolute; left: 0; top: 0;
|
||
transform: scale(var(--animskala)); transform-origin: top left;
|
||
}
|
||
|
||
.animrechts {
|
||
display: flex; flex-direction: column; gap: 7px;
|
||
min-width: 268px; max-width: 340px; align-self: start;
|
||
}
|
||
.animtitel { font-size: 12.5px; font-weight: 700; color: #E8EAEE; }
|
||
.animwerte { font-size: 11.5px; color: #6E7480; }
|
||
.animschieber { width: 100%; accent-color: #F1B729; margin: 3px 0 1px; }
|
||
.animknoepfe { display: flex; align-items: center; gap: 8px; }
|
||
.animknoepfe button {
|
||
font-family: inherit; font-size: 12px; font-weight: 700; cursor: pointer;
|
||
background: #262933; color: #C7CCD6; border: 1px solid #383C48;
|
||
border-radius: 7px; padding: 6px 13px;
|
||
}
|
||
.animknoepfe button:hover:not(:disabled) { background: #30343F; color: #E8EAEE; }
|
||
.animknoepfe button:disabled { opacity: .55; cursor: default; }
|
||
.animzeile {
|
||
font-size: 10.5px; line-height: 1.5; color: #6E7480; word-break: break-all;
|
||
}
|
||
.animzeile[data-ton="ok"] { color: #7BC48A; }
|
||
.animzeile[data-ton="err"] { color: #E5806E; }
|
||
.animzeile code, .animhin code {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
.animhin { font-size: 11.5px; line-height: 1.55; color: #6E7480; max-width: 68ch; }
|
||
.animhin code { font-size: 11px; color: #C7CCD6; }
|
||
|
||
/* ─── Rendermodus fuer den Videobau ──────────────────────────────────────
|
||
Die Seite zeigt allein das Blatt der Animation, an 0,0, in natuerlicher
|
||
Groesse. animation.mjs stellt das Fenster auf 1080 x 1350 und nimmt es
|
||
ohne Ausschnittsrechnung auf. */
|
||
html[data-nur="anim"], html[data-nur="anim"] body {
|
||
height: auto; min-height: 0; overflow: hidden;
|
||
background: #fff; display: block; padding: 0; margin: 0;
|
||
}
|
||
|
||
/* ─── Bearbeitung im Canvas ───────────────────────────────────────────────
|
||
Verschieben läuft über --ex/--ey, also über transform statt über den
|
||
Textfluss: So verschiebt ein Element nie seine Nachbarn und lässt sich
|
||
jederzeit exakt auf 0 zurücksetzen. Elemente mit eigener Transformation
|
||
komponieren die Offsets in ihre bestehende Regel hinein. */
|
||
[data-edit] { transform: translate(var(--ex, 0px), var(--ey, 0px)) rotate(var(--rot, 0deg)); }
|
||
|
||
.slide[data-editmode="an"] [data-edit] { cursor: grab; }
|
||
.slide[data-editmode="an"] [data-edit]:hover { outline: 3px dashed rgba(241,183,41,.75); outline-offset: 6px; }
|
||
.slide[data-editmode="an"] [data-edit].picked { outline: 4px solid var(--accent-yellow); outline-offset: 6px; }
|
||
|
||
/* ─── Hilfslinien beim Ziehen ────────────────────────────────────────────
|
||
Nur solange gezogen wird und nur auf der Slide, an der gezogen wird: das
|
||
Raster deckt die 1080 × 1350 ab, nicht das Canvas. Ein Raster über alle
|
||
Slides hinweg hilft nicht, weil jede Slide ihre eigene Bühne ist.
|
||
pointer-events bleiben aus, sonst reisst die Ebene den Zug ab. */
|
||
.guides { position: absolute; inset: 0; z-index: 9; display: none; pointer-events: none; }
|
||
.slide[data-guides="an"] .guides { display: block; }
|
||
/* 90 px = 1080/12 = 1350/15, teilt beide Kanten ohne Rest. */
|
||
.guides .grid {
|
||
position: absolute; inset: 0;
|
||
background-image:
|
||
repeating-linear-gradient(to right, var(--guide-line) 0 1px, transparent 1px 90px),
|
||
repeating-linear-gradient(to bottom, var(--guide-line) 0 1px, transparent 1px 90px);
|
||
}
|
||
/* Der Innenabstand aus den Regeln, damit man sieht, wann Text ihn verlässt. */
|
||
.guides .safe { position: absolute; inset: 92px; border: 2px dashed var(--guide-safe); }
|
||
.guides .ax { position: absolute; background: var(--guide-axis); }
|
||
.guides .ax-v { left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px; }
|
||
.guides .ax-h { top: 50%; left: 0; right: 0; height: 2px; margin-top: -1px; }
|
||
/* Liegt die Mitte des Elements auf der Achse, wird sie kräftig: das ist die
|
||
Rückmeldung, auf die es beim Zentrieren ankommt. */
|
||
.guides .ax.on { background: var(--accent-yellow); }
|
||
.guides .ax-v.on { width: 4px; margin-left: -2px; }
|
||
.guides .ax-h.on { height: 4px; margin-top: -2px; }
|
||
/* Fadenkreuz des gezogenen Elements, in Slide-Koordinaten gesetzt. */
|
||
.guides .mid { position: absolute; background: var(--guide-mid); }
|
||
.guides .mid-v { top: 0; bottom: 0; width: 2px; margin-left: -1px; }
|
||
.guides .mid-h { left: 0; right: 0; height: 2px; margin-top: -1px; }
|
||
|
||
/* Jede Slide trägt nicht nur ihren eigenen Inhalt, sondern auch den ihrer
|
||
Nachbarn, verschoben um deren Position. Dadurch läuft ein nach links
|
||
geschobenes Element aus Slide 3 tatsächlich in Slide 2 hinein, statt am
|
||
Rand zu verschwinden. Der Hintergrund bleibt pro Slide, die Identität
|
||
also erhalten. */
|
||
.sheet {
|
||
position: absolute; top: 0; height: 100%;
|
||
left: calc(var(--slide-i, 0) * -1080px);
|
||
width: var(--sheet-w, 5400px); z-index: 1;
|
||
}
|
||
.page { position: absolute; top: 0; width: 1080px; height: 1350px; }
|
||
|
||
/* 59 px oben: das ist die gemessene Oberkante der Ueberschrift auf der
|
||
Titel-Slide von Post 1. Seitlich bleiben es 92. */
|
||
.content { position: relative; z-index: 2; padding: 59px 92px 0; }
|
||
|
||
/* Section badge, straight from the site: .chamfer-badge in globals.css with
|
||
accent-yellow-light on accent-yellow-dark. Scaled from the 12px web size to
|
||
this canvas; the 5px corner cut scales with it.
|
||
Frueher stand hier visibility: hidden, damit das Tag seinen Platz im
|
||
Textfluss behaelt. Genau der Platz war das Problem: 54 px Hoehe plus 38 px
|
||
Abstand haben die Ueberschrift 92 px nach unten geschoben, und Post 2 sass
|
||
dadurch 125 px tiefer als die Titel-Slide von Post 1. Jetzt gibt das Tag
|
||
den Platz frei, und der obere Abstand steht direkt im padding von
|
||
.content. Wer das Tag je sichtbar macht, muss das padding mitziehen. */
|
||
.badge {
|
||
display: inline-block; margin-bottom: 38px;
|
||
font-size: 26px; font-weight: 600; letter-spacing: 0.1em;
|
||
padding: 9px 20px;
|
||
background: var(--accent-yellow-light);
|
||
color: var(--accent-yellow-dark);
|
||
display: none;
|
||
clip-path: polygon(
|
||
11px 0, calc(100% - 11px) 0, 100% 11px, 100% calc(100% - 11px),
|
||
calc(100% - 11px) 100%, 11px 100%, 0 calc(100% - 11px), 0 11px
|
||
);
|
||
}
|
||
.slide[data-tagout="an"] .badge { margin-left: -20px; }
|
||
|
||
/* 74 px ist die Grundgroesse fuer alle Slides. Die Titel-Slide hebt sie
|
||
ueber den Projektstand auf 88 an, genau wie Post 1 es bei seiner Tagline
|
||
macht. Fuer alle sechs ginge 88 nicht: gemessen ist "Dein
|
||
Branchenprogramm" dann 1022 px breit, verfuegbar sind 896, und die
|
||
Ueberschriften von Slide 2, 4 und 5 wuerden dreizeilig. */
|
||
.headline {
|
||
font-size: 74px; font-weight: 800;
|
||
letter-spacing: -0.03em; line-height: 1.06;
|
||
color: var(--head-color);
|
||
}
|
||
.headline .a, .title-head .a, .wabe-tagline .a {
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 48' preserveAspectRatio='none'%3E%3Cpath d='M1.5,12 C58,4.5 168,9.5 312,5 C316,7 318,11 317.5,16 C318.5,26 316,36 313,42.5 C232,47.5 104,41.5 6.5,44.5 C2.5,42 0.5,36 1,29 C0.5,22 0.8,16.5 1.5,12 Z' fill='%23F1B729'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: 0 68%;
|
||
background-size: 100% 62%;
|
||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||
padding: 0 14px; margin: 0 -6px;
|
||
}
|
||
|
||
.body {
|
||
margin-top: 30px; font-size: 30px; font-weight: 500;
|
||
line-height: 1.48; color: var(--body-color); max-width: 830px;
|
||
}
|
||
|
||
/* ─── Device mockup ──────────────────────────────────────────────────────
|
||
A chamfered frame, not a photoreal laptop: the website already frames every
|
||
screenshot this way (.screenshot-frame in globals.css), so this is the
|
||
on-brand shape and it ties the posts to the site. */
|
||
.mock {
|
||
position: absolute; z-index: 1;
|
||
left: var(--mock-left, 92px);
|
||
width: var(--mock-w, 1090px); /* default bleeds ~100px past the right edge */
|
||
bottom: 96px;
|
||
padding: 12px;
|
||
background: var(--frame-bg);
|
||
clip-path: polygon(
|
||
22px 0, calc(100% - 22px) 0, 100% 22px, 100% calc(100% - 22px),
|
||
calc(100% - 22px) 100%, 22px 100%, 0 calc(100% - 22px), 0 22px
|
||
);
|
||
/* Rahmenform ist umschaltbar. Bei "rund" muss clip-path weichen, sonst
|
||
gewinnt der Schnitt gegen border-radius; bei "aus" fällt auch Polsterung
|
||
und Fläche weg.
|
||
Kein Schatten: die Gestaltung arbeitet flächig, Tiefe entsteht über
|
||
Überlappung und Rahmenfarbe, nicht über Weichzeichnung. */
|
||
}
|
||
.slide[data-tilt="an"] .mock {
|
||
transform: translate(var(--ex, 0px), var(--ey, 0px))
|
||
perspective(2600px) rotateY(-7deg) rotateX(2.5deg)
|
||
rotate(calc(-1.2deg + var(--rot, 0deg)));
|
||
}
|
||
|
||
.mock-inner {
|
||
position: relative; overflow: hidden;
|
||
aspect-ratio: var(--mock-ar, 1.6); width: 100%;
|
||
background: #fff;
|
||
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
|
||
);
|
||
}
|
||
|
||
/* Ein zweites Bild pro Slide. Es ist bewusst wieder ein .mock, damit alle
|
||
Rahmenformen, Schatten und Perspektiven ohne Sonderregeln greifen; nur
|
||
Position, Größe und Stapelebene kommen von aussen. */
|
||
.mock-2 {
|
||
z-index: var(--m2-z, 2);
|
||
bottom: var(--m2-bottom, 96px);
|
||
}
|
||
.slide[data-frame="stapel"] .mock-2 .sheet-back,
|
||
.slide[data-frame="stapel"] .mock-2 .sheet-mid { display: none; }
|
||
|
||
/* ─── Notizzettel ────────────────────────────────────────────────────────
|
||
Nachbau der StickyNote aus dem Pitch-Projekt
|
||
(ui/components/StickyNote/StickyNote.tsx), Variante "purple". Farbwerte
|
||
und Aufbau sind von dort übernommen, die Maße hochskaliert: die Vorlage
|
||
ist 200 px breit bei 14 px Text, hier liegt sie auf einem 1080er Blatt.
|
||
Bewusst echtes HTML statt Screenshot, damit die Kanten beim Export
|
||
genauso scharf sind wie die Schrift und der Text austauschbar bleibt. */
|
||
.note {
|
||
position: absolute; z-index: var(--note-z, 3);
|
||
left: var(--note-left, 60px);
|
||
bottom: var(--note-bottom, 150px);
|
||
width: var(--note-w, 320px);
|
||
border-radius: 10px; overflow: hidden;
|
||
font-family: Manrope, sans-serif;
|
||
}
|
||
.note-head {
|
||
height: 50px; background: #ce93d8;
|
||
display: flex; align-items: center; justify-content: flex-end;
|
||
padding-right: 12px;
|
||
}
|
||
.note-head svg { width: 26px; height: 26px; display: block; }
|
||
.note-body {
|
||
background: #f3e5f5; color: #424242;
|
||
padding: 18px 20px 26px; min-height: 168px;
|
||
font-size: 23px; font-weight: 400; line-height: 1.5;
|
||
}
|
||
/* Der Anfasser unten rechts: zwei ineinanderliegende Ecken, wie im Original
|
||
zwei Spans mit border-b/border-r in zwei Deckkraften. */
|
||
.note-grip { position: absolute; right: 9px; bottom: 9px; }
|
||
.note-grip i {
|
||
position: absolute; right: 0; bottom: 0; display: block;
|
||
border-right: 2px solid rgba(66,66,66,.45);
|
||
border-bottom: 2px solid rgba(66,66,66,.45);
|
||
}
|
||
.note-grip i:first-child { width: 17px; height: 17px; }
|
||
.note-grip i:last-child {
|
||
width: 10px; height: 10px;
|
||
border-right-color: rgba(66,66,66,.65);
|
||
border-bottom-color: rgba(66,66,66,.65);
|
||
}
|
||
|
||
.slide[data-frame="rund"] .mock { clip-path: none; border-radius: 30px; }
|
||
.slide[data-frame="rund"] .mock-inner { clip-path: none; border-radius: 20px; }
|
||
|
||
.slide[data-frame="aus"] .mock {
|
||
clip-path: none; padding: 0; background: none; border-radius: 0;
|
||
}
|
||
.slide[data-frame="aus"] .mock-inner { clip-path: none; border-radius: 0; }
|
||
|
||
/* ─── Stapel ─────────────────────────────────────────────────────────────
|
||
Zwei versetzte Blätter hinter dem Screenshot. Der Rahmen selbst fällt weg.
|
||
Getrennt werden die Blätter nicht durch Schatten, sondern durch den
|
||
Versatz und eine Haarlinie: auf der hellen Variante läge sonst Weiss auf
|
||
Weiss und der Stapel wäre nicht mehr zu erkennen. */
|
||
.sheet-back, .sheet-mid {
|
||
display: none; position: absolute; inset: 0; z-index: 0;
|
||
background: #fff; border-radius: 22px; overflow: hidden;
|
||
border: 1px solid rgba(48,49,61,.18);
|
||
}
|
||
.slide[data-frame="stapel"] .mock {
|
||
clip-path: none; padding: 0; background: none;
|
||
}
|
||
.slide[data-frame="stapel"] .mock-inner {
|
||
clip-path: none; border-radius: 22px; z-index: 2;
|
||
border: 1px solid rgba(48,49,61,.18);
|
||
}
|
||
.slide[data-frame="stapel"] .sheet-mid {
|
||
display: block; z-index: 1;
|
||
transform: rotate(-2.4deg) translate(-2.2%, 3%) scale(.975);
|
||
}
|
||
.slide[data-frame="stapel"] .sheet-back {
|
||
display: block;
|
||
transform: rotate(3.2deg) translate(3%, 5.5%) scale(.95);
|
||
}
|
||
/* Das hinterste Blatt zeigt bewusst einen anderen Screen: der Stapel soll
|
||
nach mehreren Ansichten aussehen, nicht nach einer Kopie. */
|
||
.sheet-back img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
}
|
||
|
||
/* Screens are ~2000px wide landscape captures; showing them whole makes the
|
||
UI text unreadable at this size, so each one is zoomed onto its telling
|
||
region via the per-slide vars below. */
|
||
.mock-inner img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(var(--img-scale, 1)) translate(var(--img-x, 0%), var(--img-y, 0%));
|
||
}
|
||
|
||
/* ─── Title layout "wabe" ────────────────────────────────────────────────
|
||
The screenshot sits inside the brand hexagon. Its proportions are taken
|
||
from the logo mark itself (384.5 x 332.9, i.e. 2/sqrt3), so the shape is a
|
||
true regular hexagon rather than a squashed one. */
|
||
.hex-frame {
|
||
padding: 15px; background: var(--frame-bg);
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.hex-shot {
|
||
position: relative; overflow: hidden;
|
||
width: 748px; height: 648px;
|
||
background: #fff;
|
||
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
|
||
}
|
||
.hex-shot img {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(1.533) translate(-17.4%, 0%);
|
||
}
|
||
|
||
/* Laptop mockup. The display was knocked out of the PNG, so the screenshot
|
||
sits BEHIND the device and the notch plus the rounded corners stay on top,
|
||
the way a real mockup reads. Rect measured from the file: 739 x 489 of
|
||
1050 x 700. */
|
||
.laptop { position: relative; width: 960px; }
|
||
.laptop-frame { width: 100%; display: block; position: relative; z-index: 2; }
|
||
.laptop-screen {
|
||
position: absolute; z-index: 1; overflow: hidden;
|
||
left: 14.857%; top: 11.857%; width: 70.381%; height: 69.857%;
|
||
background: #fff;
|
||
}
|
||
/* Scoped to the screenshot: an unscoped ".laptop-screen img" also caught the
|
||
wordmark in the logo variant and forced it to width 100% at top left. */
|
||
.laptop-screen > img.laptop-shot {
|
||
position: absolute; top: 0; left: 0; width: 100%;
|
||
transform-origin: top left;
|
||
transform: scale(1.566) translate(-18.1%, 0%);
|
||
}
|
||
/* Blurred variant: just enough softening to seat the wordmark, the app stays
|
||
readable underneath. Slightly overscaled so the blur never pulls the empty
|
||
edge of the clip box into frame, and re-centred for that scale. */
|
||
.laptop-screen[data-blur="an"] > img.laptop-shot {
|
||
filter: blur(5px);
|
||
transform: scale(1.04) translate(-1.9%, -2.2%);
|
||
}
|
||
|
||
/* Variant with the wordmark on the display: dead centre, same 430px as the
|
||
lockup in the plain laptop layout. No scrim, the screenshot stays as it is. */
|
||
.laptop-screen > img.screen-logo {
|
||
position: absolute; z-index: 2;
|
||
left: 50%; top: 50%;
|
||
transform: translate(-50%, -50%) translate(var(--ex, 0px), var(--ey, 0px));
|
||
width: 430px; height: auto;
|
||
}
|
||
|
||
/* ─── Laptop mit Board ───────────────────────────────────────────────────
|
||
Fertiges Mockup: der Screen ist Teil des Bildes. Kein ausgeknocktes
|
||
Display, keine Projektion per matrix3d, kein zweiter Screenshot dahinter.
|
||
Damit fallen auch die gemessenen Vierecke und Eckradien weg, die das
|
||
vorherige Szenen-Mockup gebraucht hat. */
|
||
/* Breiter als das vorherige Mockup, weil dieses Bild links und rechts rund
|
||
ein Viertel freien Rand mitbringt: bei 880px ist das Geraet selbst wieder
|
||
etwa so gross wie vorher, und 880 bleibt innerhalb der 888px, die zwischen
|
||
den Innenraendern der Slide frei sind. */
|
||
.szene { position: relative; width: 880px; }
|
||
.szene-dev { width: 100%; height: auto; display: block; }
|
||
|
||
/* Das Mockup steht im DOM nach der Ueberschrift und lag damit darueber.
|
||
Ein gross gezogenes Geraet hat sie deshalb verdeckt und im
|
||
Bearbeiten-Modus die Klicks geschluckt, sodass man nicht mehr an sie
|
||
herankam. Die Zeile oben liegt jetzt darueber. */
|
||
.page.title[data-layout="szene"] .title-top { position: relative; z-index: 3; }
|
||
.page.title[data-layout="szene"] .szene { z-index: 1; }
|
||
|
||
.laptop-missing {
|
||
width: 940px; padding: 80px 40px; text-align: center;
|
||
border: 3px dashed var(--shape-line); color: var(--body-color);
|
||
font-size: 26px; line-height: 1.5;
|
||
}
|
||
|
||
.title-top { transform: translateX(-140px); }
|
||
.title-top[data-edit] { transform: translate(calc(-140px + var(--ex, 0px)), var(--ey, 0px)); }
|
||
/* Footer variant: everything on the centre axis, wordmark closes the slide.
|
||
No left shift here, so the lockup is not tied to the dark block. */
|
||
.page.title[data-layout="laptop-footer"] .title-top { transform: none; }
|
||
.page.title[data-layout="laptop-footer"] .wabe-claim { margin-top: 26px; }
|
||
.page.title[data-layout="laptop-footer"] .wabe-logo { margin-top: 0; }
|
||
|
||
.page.title[data-layout="laptop-logo"] .wabe-tagline,
|
||
.page.title[data-layout="laptop-blur"] .wabe-tagline { margin-top: 172px; }
|
||
.wabe-logo { width: 430px; height: auto; display: block; margin-top: 37px; }
|
||
.wabe-tagline {
|
||
white-space: nowrap;
|
||
font-size: 74px; font-weight: 800; line-height: 1.06;
|
||
letter-spacing: -0.03em; color: var(--head-color);
|
||
}
|
||
.wabe-claim {
|
||
margin-top: 4px; font-size: 31px; font-weight: 500; line-height: 1.44;
|
||
color: var(--body-color); max-width: 800px;
|
||
}
|
||
|
||
/* ─── Title slide ────────────────────────────────────────────────────── */
|
||
.page.title .content {
|
||
padding: 0 104px; height: 100%;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
text-align: center;
|
||
}
|
||
.page.title[data-layout="wabe"] .content,
|
||
.page.title[data-layout="laptop"] .content,
|
||
.page.title[data-layout="laptop-logo"] .content,
|
||
.page.title[data-layout="laptop-blur"] .content,
|
||
.page.title[data-layout="laptop-footer"] .content,
|
||
.page.title[data-layout="szene"] .content {
|
||
padding: 92px 96px; justify-content: space-between;
|
||
}
|
||
.logo-full { width: 560px; height: auto; display: block; margin-bottom: 74px; }
|
||
.slide[data-variant="dunkel"] .logo-dark,
|
||
.slide[data-variant="hell"] .logo-light { display: none; }
|
||
|
||
.title-head {
|
||
font-size: 88px; font-weight: 800;
|
||
letter-spacing: -0.035em; line-height: 1.04;
|
||
color: var(--head-color);
|
||
}
|
||
.title-sub {
|
||
margin-top: 40px; font-size: 31px; font-weight: 500; line-height: 1.45;
|
||
color: var(--body-color); max-width: 760px;
|
||
}
|
||
|
||
|
||
/* ─── Variant: dunkel ────────────────────────────────────────────────── */
|
||
.slide[data-variant="dunkel"] {
|
||
background: var(--dark-bg);
|
||
--head-color: var(--text-white);
|
||
--body-color: #B9BDC9;
|
||
--accent-on: var(--accent-yellow);
|
||
--marker: var(--accent-yellow);
|
||
--frame-bg: rgba(255,255,255,.11);
|
||
--shape-fill: #414252;
|
||
--shape-line: rgba(255,255,255,.10);
|
||
--pat-color: #FFFFFF; --pat-strength: .07;
|
||
--gmark-color: rgba(255,255,255,.13);
|
||
--guide-line: rgba(255,255,255,.16);
|
||
--guide-safe: rgba(255,255,255,.26);
|
||
--guide-axis: rgba(255,255,255,.40);
|
||
--guide-mid: rgba(241,183,41,.80);
|
||
}
|
||
.slide[data-variant="dunkel"] .headline .a,
|
||
.slide[data-variant="dunkel"] .title-head .a { color: #1A1A1A; }
|
||
|
||
/* ─── Variant: hell ──────────────────────────────────────────────────── */
|
||
.slide[data-variant="hell"] {
|
||
background: var(--surface);
|
||
--head-color: var(--text-primary);
|
||
--body-color: var(--text-secondary);
|
||
--accent-on: #C8901A;
|
||
--marker: var(--accent-yellow);
|
||
--frame-bg: #E8EBF0;
|
||
--shape-fill: var(--dark-bg);
|
||
--shape-line: rgba(48,49,61,.13);
|
||
--pat-color: #30313D; --pat-strength: .07;
|
||
--gmark-color: rgba(48,49,61,.16);
|
||
--guide-line: rgba(48,49,61,.20);
|
||
--guide-safe: rgba(48,49,61,.30);
|
||
--guide-axis: rgba(48,49,61,.45);
|
||
--guide-mid: rgba(200,144,26,.75);
|
||
}
|
||
|
||
/* ─── Uebersichts-Modus ──────────────────────────────────────────────────
|
||
Die Seite zeigt dann nur den Streifen, in natuerlicher Groesse und ohne
|
||
eigenen Rahmen: die Uebersicht setzt sie als iframe ein und skaliert
|
||
selbst. Alles Bedienbare faellt weg, auch die Hilfslinien. */
|
||
html[data-nur="streifen"] { overflow: hidden; }
|
||
html[data-nur="streifen"] .menubar,
|
||
html[data-nur="streifen"] .panel,
|
||
html[data-nur="streifen"] .hud,
|
||
html[data-nur="streifen"] #animleiste,
|
||
html[data-nur="streifen"] .guides { display: none !important; }
|
||
/* Der Auswahlrahmen ist eine Bedienhilfe und hat in der Uebersicht nichts
|
||
verloren -- dort ist keine Slide "die aktuelle". */
|
||
html[data-nur="streifen"] .holder.sel::after { display: none; }
|
||
html[data-nur="streifen"] .stage { background: none; height: auto; }
|
||
html[data-nur="streifen"] .scroller { position: static; overflow: visible; }
|
||
html[data-nur="streifen"] .canvas { padding: 0; display: block;
|
||
width: max-content; min-width: 0; height: max-content; min-height: 0; }
|
||
html[data-nur="streifen"] body, html[data-nur="streifen"] .app {
|
||
height: auto; min-height: 0; background: none; overflow: visible;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="menubar" id="menubar">
|
||
<span class="marke">PostGrid</span>
|
||
|
||
<div class="menu" data-menu="projekt">
|
||
<button type="button">Projekt</button>
|
||
<div class="liste">
|
||
<button type="button" data-gehe="./post1.html">Post 1</button>
|
||
<button type="button" data-gehe="./post2.html">Post 2</button>
|
||
<button type="button" data-gehe="./post2-alt.html">Post 2 · Alternative</button>
|
||
<button type="button" data-gehe="./post3.html">Post 3 · Verbinden statt ersetzen</button>
|
||
<button type="button" data-gehe="./post4.html">Post 4 · Workflow-Engine</button>
|
||
<button type="button" data-gehe="./post5.html">Post 5 · Projektmanagement</button>
|
||
<button type="button" data-gehe="./post6.html">Post 6 · Team-Chat</button>
|
||
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
|
||
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
|
||
<button type="button" data-gehe="./post9.html">Post 9 · Variante 1 · Webinar-Fenster</button>
|
||
<button type="button" data-gehe="./post9-v2.html">Post 9 · Variante 2 · Kundengeschichte</button>
|
||
<button type="button" data-gehe="./post9-v3.html">Post 9 · Variante 3 · Kundengeschichte dunkel</button>
|
||
<button type="button" data-gehe="./post9-v4.html">Post 9 · Variante 4 · Zahlen</button>
|
||
<button type="button" data-gehe="./post9-v5.html">Post 9 · Variante 5 · Zahlen dunkel</button>
|
||
<button type="button" data-gehe="./post9-varianten.html">Post 9 · Varianten im Vergleich</button>
|
||
<hr>
|
||
<button type="button" data-gehe="./uebersicht.html">Alle Projekte im Streifen</button>
|
||
<hr>
|
||
<button type="button" data-klick="snap-save">Stand merken</button>
|
||
<button type="button" data-klick="snap-load">Zurück zum gemerkten Stand</button>
|
||
<hr>
|
||
<button type="button" data-klick="forget">Projekt zurücksetzen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="datei">
|
||
<button type="button">Datei</button>
|
||
<div class="liste">
|
||
<button type="button" data-klick="bild-waehlen">Bild importieren</button>
|
||
<hr>
|
||
<button type="button" data-klick="dl-png">Diese Slide laden<span class="kurz">D</span></button>
|
||
<button type="button" data-klick="dl-all">Alle Slides als ZIP<span class="kurz">A</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="bearbeiten">
|
||
<button type="button">Bearbeiten</button>
|
||
<div class="liste">
|
||
<button type="button" data-tun="undo">Rückgängig<span class="kurz">⌘Z</span></button>
|
||
<button type="button" data-tun="redo">Wiederherstellen<span class="kurz">⇧⌘Z</span></button>
|
||
<hr>
|
||
<button type="button" data-klick="reset-one">Element auf Standard</button>
|
||
<button type="button" data-klick="reset-all">Alles auf Standard</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="slide">
|
||
<button type="button">Slide</button>
|
||
<div class="liste">
|
||
<button type="button" data-klick="slide-neu">Neu dahinter</button>
|
||
<button type="button" data-klick="slide-kopie">Kopieren</button>
|
||
<button type="button" data-klick="slide-weg">Löschen</button>
|
||
<hr>
|
||
<button type="button" data-klick="slide-links">Nach vorn</button>
|
||
<button type="button" data-klick="slide-rechts">Nach hinten</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="menu" data-menu="ansicht">
|
||
<button type="button">Ansicht</button>
|
||
<div class="liste">
|
||
<button type="button" data-tun="vorschau">Vorschau<span class="kurz">P</span></button>
|
||
<hr>
|
||
<button type="button" data-opt="views:w:streifen">Streifen<span class="kurz">W</span></button>
|
||
<button type="button" data-opt="views:w:einzeln">Einzeln</button>
|
||
<hr>
|
||
<button type="button" data-klick="zoom-fit">Einpassen<span class="kurz">0</span></button>
|
||
<button type="button" data-klick="zoom-in">Größer<span class="kurz">+</span></button>
|
||
<button type="button" data-klick="zoom-out">Kleiner<span class="kurz">−</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="arbeitsflaeche">
|
||
|
||
<aside class="panel">
|
||
<h1>Post 2</h1>
|
||
<p class="sub"><span id="kopfzeile">Carousel · 1080 × 1350 px</span><span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
|
||
|
||
<div class="group">
|
||
<label>Slide</label>
|
||
<div class="opts one" id="slides-nav"></div>
|
||
<div class="opts" id="slide-ops" style="margin-top:8px">
|
||
<button id="slide-neu" title="Neue Slide dahinter einfügen">+ Neu</button>
|
||
<button id="slide-kopie" title="Diese Slide kopieren">Kopie</button>
|
||
<button id="slide-weg" title="Diese Slide löschen">Löschen</button>
|
||
<button id="slide-links" title="Eine Position nach vorn">←</button>
|
||
<button id="slide-rechts" title="Eine Position nach hinten">→</button>
|
||
</div>
|
||
</div>
|
||
|
||
<details class="group" data-grp="inhalt-nur-diese-slide">
|
||
<summary>Inhalt (nur diese Slide)</summary>
|
||
<div id="inhalt"></div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="eigene-bilder">
|
||
<summary>Eigene Bilder</summary>
|
||
<input type="file" id="bild-datei" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple hidden>
|
||
<button class="btn secondary" id="bild-waehlen" style="width:100%">Bilder importieren …</button>
|
||
<div class="hint" id="bild-hinweis">Das Bild landet mittig auf der gewählten Slide.
|
||
Auf eine andere Slide ziehen legt es dort ab. Mit <b>E</b> lässt es sich schieben
|
||
und über <b>A−</b>/<b>A+</b> in der Größe ändern.<br>
|
||
Die Datei landet unverändert unter <b>assets/eigene/</b> und gehört mit in den Commit.</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="variante">
|
||
<summary>Variante</summary>
|
||
<div class="opts" id="variants">
|
||
<button data-v="dunkel" aria-pressed="true"><span class="swatch" style="background:#30313D"></span>Dunkel</button>
|
||
<button data-v="hell" aria-pressed="false"><span class="swatch" style="background:#FFFFFF"></span>Hell</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Nicht mehr in der Seitenleiste: Layout und Perspektive sind
|
||
post-spezifisch, Vorschau steht oben unter Ansicht, der Tag ist
|
||
ausgeblendet. Die Schalter bleiben trotzdem im Dokument, weil die
|
||
Menueleiste, die Tastenkuerzel und die Abgleich-Funktionen sie
|
||
betaetigen. Sonst gaebe es zwei Wahrheiten ueber denselben Zustand. -->
|
||
<div hidden>
|
||
<div class="opts" id="layouts"></div>
|
||
<div class="opts" id="tagouts">
|
||
<button data-o="an" aria-pressed="true">An</button>
|
||
<button data-o="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
<div class="opts" id="vorschau">
|
||
<button data-p="aus" aria-pressed="true">Aus</button>
|
||
<button data-p="an" aria-pressed="false">An</button>
|
||
</div>
|
||
<div class="opts" id="ints">
|
||
<button data-i="dezent" aria-pressed="true">Dezent</button>
|
||
<button data-i="kraeftig" aria-pressed="false">Kräftig</button>
|
||
</div>
|
||
<div class="opts" id="tilts">
|
||
<button data-t="an" aria-pressed="true">Gekippt</button>
|
||
<button data-t="aus" aria-pressed="false">Gerade</button>
|
||
</div>
|
||
<button id="reset-all">Alles auf Standard</button>
|
||
</div>
|
||
|
||
<details class="std">
|
||
<summary>Standardwerte</summary>
|
||
<div id="std-list"></div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="ansicht">
|
||
<summary>Ansicht</summary>
|
||
<div class="opts" id="views">
|
||
<button data-w="streifen" aria-pressed="true">Streifen</button>
|
||
<button data-w="einzeln" aria-pressed="false">Einzeln</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="bildrahmen">
|
||
<summary>Bildrahmen</summary>
|
||
<div class="opts three" id="frames">
|
||
<button data-f="chamfer" aria-pressed="true">Chamfer</button>
|
||
<button data-f="rund" aria-pressed="false">Rund</button>
|
||
<button data-f="stapel" aria-pressed="false">Stapel</button>
|
||
<button data-f="aus" aria-pressed="false">Aus</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="renderqualit-t">
|
||
<summary>Renderqualität</summary>
|
||
<div class="opts three" id="quality">
|
||
<button data-q="1" aria-pressed="false">1×</button>
|
||
<button data-q="2" aria-pressed="true">2×</button>
|
||
<button data-q="3" aria-pressed="false">3×</button>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Der Export steht oben unter Datei. Die Knoepfe bleiben trotzdem im
|
||
Dokument: die Menueleiste betaetigt sie, und sie tragen den Zustand
|
||
"noch nicht bereit", solange die Schrift nicht geladen ist. Ohne sie
|
||
muesste beides doppelt gefuehrt werden. -->
|
||
<div hidden>
|
||
<button id="dl-png" disabled>Diese Slide</button>
|
||
<button id="dl-all" disabled>Alle Slides als ZIP</button>
|
||
</div>
|
||
|
||
<details class="group" style="margin-bottom:0" data-grp="tastatur">
|
||
<summary>Tastatur</summary>
|
||
<div class="keys">
|
||
<kbd>←</kbd><kbd>→</kbd> Slide · <kbd>V</kbd> Variante<br>
|
||
<kbd>L</kbd> Layout · <kbd>I</kbd> Intensität<br>
|
||
<kbd>W</kbd> Ansicht<br>
|
||
<kbd>R</kbd> Bildrahmen<br>
|
||
<kbd>T</kbd> Perspektive<br>
|
||
<kbd>P</kbd> Vorschau · Pfeile verschieben (<kbd>⇧</kbd> = 10 px)<br>
|
||
<kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> Zoom · ⌘/Strg + Rad<br>
|
||
<kbd>Leer</kbd> + ziehen verschiebt die Fläche<br>
|
||
<kbd>⌘/Strg</kbd>+<kbd>Z</kbd> Rückgängig (<kbd>⇧</kbd> = wiederherstellen)<br>
|
||
<kbd>D</kbd> Slide laden · <kbd>A</kbd> alle als ZIP
|
||
</div>
|
||
</details>
|
||
|
||
<div class="status" id="status">Lade Schriften und Screens ...</div>
|
||
</aside>
|
||
|
||
<main class="stage" id="stage">
|
||
<div class="scroller" id="scroller">
|
||
<div class="canvas">
|
||
<div class="strip-wrap" id="strip-wrap">
|
||
<div class="strip" id="deck" data-view="streifen"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="hud">
|
||
<p class="stage-meta" id="meta"></p>
|
||
<div class="snap">
|
||
<button id="snap-save" title="Diesen Stand als Rückfallpunkt ablegen">Stand merken</button>
|
||
<button id="snap-load" title="Zum gemerkten Stand zurück" disabled>Zurück zum Stand</button>
|
||
</div>
|
||
<div class="zoom">
|
||
<button id="zoom-out" title="Auszoomen">−</button>
|
||
<button class="level" id="zoom-fit" title="Einpassen">Fit</button>
|
||
<button id="zoom-in" title="Einzoomen">+</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<aside class="panel panel-rechts" id="element-panel">
|
||
<!-- Der Schliessknopf steht vor der Ueberschrift und nicht dahinter. Am
|
||
rechten Rand war er der erste, der verschwand, sobald das Feld nicht
|
||
ganz ins Fenster passte -- und damit war das Feld nicht mehr
|
||
zuzumachen. Vorn ist er immer da. -->
|
||
<div class="el-kopf">
|
||
<button class="el-zu" id="el-zu" title="Auswahl aufheben (Esc)">✕</button>
|
||
<h1 id="el-titel">Element</h1>
|
||
</div>
|
||
<div id="el-inhalt"></div>
|
||
</aside>
|
||
</div>
|
||
|
||
<script src="./vendor/html-to-image.js"></script>
|
||
<script src="./vendor/jszip.min.js"></script>
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
/* A syntax or temporal-dead-zone error at this level kills the whole script
|
||
silently: the panel renders from static markup, but nothing is ever built
|
||
and no status is set. Surface it instead of showing an empty stage. */
|
||
window.addEventListener("error", e => {
|
||
const el = document.getElementById("status");
|
||
if (el) {
|
||
el.textContent = "Skriptfehler: " + (e.message || e.error);
|
||
el.className = "status err";
|
||
}
|
||
});
|
||
|
||
const deck = document.getElementById("deck");
|
||
const nav = document.getElementById("slides-nav");
|
||
const statusEl = document.getElementById("status");
|
||
const metaEl = document.getElementById("meta");
|
||
|
||
/* Slide content. Feature bodies are the website copy verbatim
|
||
(app/playground/PlaygroundClient.tsx → solutionModules) so Social and
|
||
Website say exactly the same thing; the headlines are new and short,
|
||
because a full sentence does not work as a carousel hook.
|
||
img-scale/x/y frame each capture on its telling region. */
|
||
/* ─── Die Geschichte ──────────────────────────────────────────────────────
|
||
Post 2 erzaehlt die Herkunft, nicht die Funktionen. Statt Screenshots
|
||
traegt jede Slide eine gebaute Visualisierung: der Text nennt ein
|
||
Stichwort, das Bild zeigt es. Alle sind aus derselben Formensprache wie
|
||
die Slides selbst gebaut, also chamferte Kaesten, Sechsecke und der eine
|
||
gelbe Akzent. Kein Bildmaterial ausser Noahs Karte. */
|
||
/* ─── Die Geschichte ──────────────────────────────────────────────────────
|
||
Post 2 erzaehlt die Herkunft, nicht die Funktionen. Statt Screenshots
|
||
traegt jede Slide eine gebaute Visualisierung: der Text nennt ein
|
||
Stichwort, das Bild zeigt es.
|
||
|
||
Die Texte sind Zuspitzungen von Noahs Text, nicht dessen Absaetze: auf
|
||
einer Slide muss eine Zeile im Vorbeiscrollen sitzen. Formulierungen und
|
||
Stimme kommen aber von dort, nicht aus dem Nichts.
|
||
|
||
Der Beitrag ist Noahs Konzept, sagt das aber nirgends geradeheraus. Er
|
||
baut es auf: Slide 2 stellt im Fliesstext fest, dass hinter den
|
||
gewachsenen Ablaeufen keines steckt, Slide 5 liefert dann eines. Das Wort
|
||
faellt genau einmal und wird danach nicht wiederholt.
|
||
|
||
Slide 2 muss weich an Slide 1 anschliessen. Dort stellt sich Noah vor,
|
||
hier folgte frueher sofort die Diagnose -- ein harter Schnitt.
|
||
|
||
Die Ueberleitung steckt in Noahs Satzbau selbst: er raeumt die Erfahrung
|
||
erst ein und dreht dann ("hat nicht nur Erfahrung. Er hat auch Prozesse,
|
||
die seit Jahren so laufen"). Wer zuerst zustimmt, darf danach widersprechen.
|
||
Der Fliesstext behaelt seine Konstruktion mit dem nachgestellten "Nicht,
|
||
weil ..." -- das ist bei ihm ein Stilmittel, keine abgehackte Halbzeile.
|
||
|
||
Das Wort Zettelwirtschaft ist aus der Ueberschrift raus. Die Zeile sagt
|
||
"nicht nur Erfahrung", und was das andere ist, zeigt die Zeichnung
|
||
daneben. Es zweimal zu sagen waere einmal zu viel.
|
||
|
||
Wichtiger als die Kuerze ist die Satzform. In einer frueheren Fassung
|
||
hatten alle fuenf Ueberschriften dieselbe: zwei abgehackte Fragmente mit
|
||
Punkt dazwischen, fuenfmal hintereinander. Das liest sich maschinell,
|
||
egal wie gut der einzelne Satz ist. Jetzt wechseln sich Aussage mit
|
||
Wendung, Frage und Antwort, Paar und durchlaufender Satz ab.
|
||
|
||
Faustregel aus den gemessenen Breiten: bei 74 px und -0,03em passen rund
|
||
26 Zeichen in eine Zeile, mit Marker etwas weniger. Keine Zeile hier ist
|
||
laenger als 22. */
|
||
const VORLAGEN = [
|
||
{
|
||
key: "titel", type: "feature",
|
||
layouts: [{ id: "ecke", label: "Helm auf der Ecke" },
|
||
{ id: "kopf", label: "Helm auf dem Kopf" }],
|
||
eyebrow: "Titel",
|
||
head: 'Die Geschichte fängt<br><span class="a">auf der Baustelle an.</span>',
|
||
body: "Noah Geerkens, SHK-Betrieb in vierter Generation.",
|
||
vis: "karte", visW: 620, visLeft: 230, visBottom: 76,
|
||
},
|
||
{
|
||
key: "prozesse", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Zettelwirtschaft",
|
||
head: 'Wer lange dabei ist,<br><span class="a">hat nicht nur Erfahrung.</span>',
|
||
body: "Auch Abläufe, die laufen, weil die Software es vorgibt. Nicht, weil ein Konzept dahintersteckt.",
|
||
vis: "schiene", visW: 896, visLeft: 92, visBottom: 120,
|
||
},
|
||
{
|
||
key: "wechseln", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Wechseln",
|
||
head: 'Und wechseln?<br><span class="a">Kannst du vergessen.</span>',
|
||
body: "Da hängen Jahre an Stammdaten, Aufträgen und Buchhaltung drin. Ein Wechsel führt ins Chaos.",
|
||
vis: "ersetzen", visW: 896, visLeft: 92, visBottom: 190,
|
||
},
|
||
{
|
||
key: "mehr", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Mehr",
|
||
head: 'Wir wollten nicht weg.<br><span class="a">Wir wollten mehr.</span>',
|
||
body: "KI-Lösungen, Projektmanagement, Automatisierungen. Unsere Tools sollen endlich miteinander reden.",
|
||
vis: "lose", visW: 988, visLeft: 92, visBottom: 120,
|
||
},
|
||
{
|
||
key: "verbinden", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Verbinden",
|
||
head: 'Dein Branchenprogramm<br><span class="a">bleibt, wo es ist.</span>',
|
||
body: "Kein Ersatz, sondern der Aufbau drumherum. HiveGrid holt raus, was bisher gefehlt hat.",
|
||
vis: "verbinden", visW: 988, visLeft: 0, visBottom: 120,
|
||
},
|
||
{
|
||
key: "warteliste", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Warteliste",
|
||
/* Die Marken-Tagline. Sie wird genauso ausgezeichnet wie in Post 1 und
|
||
im Titel-Layout: der Marker sitzt auf dem zweiten "Handwerker", nicht
|
||
auf der ganzen Zeile. Eine zweite Schreibweise derselben Zeile waere
|
||
eine Abweichung, die niemandem auffaellt und trotzdem stoert. */
|
||
head: 'Vom Handwerker<br>für <span class="a">Handwerker</span>',
|
||
body: "Kein Startup-Projekt. Setz dich auf die Warteliste, dann melden wir uns.",
|
||
vis: "warteliste", visW: 896, visLeft: 92, visBottom: 120,
|
||
},
|
||
];
|
||
|
||
/* Vorlage für eine selbst angelegte Slide. Sie startet bewusst mit Text
|
||
drin: eine leere Slide sieht aus wie ein Fehler, und man sieht nicht,
|
||
welches Feld welche Zeile füllt. */
|
||
const kopie = x => JSON.parse(JSON.stringify(x));
|
||
|
||
const NEUE_SLIDE = {
|
||
type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Neuer Tag",
|
||
head: 'Überschrift mit <span class="a">Akzent</span>',
|
||
body: "Beschreibung dieser Slide. Zwei bis drei Zeilen sitzen am besten.",
|
||
img: "./assets/screens/pipelines.png",
|
||
mockW: 1090, mockAR: 1.6,
|
||
imgScale: 1, imgX: "0%", imgY: "0%",
|
||
};
|
||
|
||
/* Alle Screens im Ordner. Eine selbst angelegte Slide kann jeden davon
|
||
wählen, deshalb werden sie vollständig geladen und nicht nur die, die
|
||
eine Vorlage zufällig gerade benutzt. */
|
||
/* Ausgangsliste. Der Server liefert beim Start die tatsaechlich vorhandenen
|
||
Dateien nach, damit importierte Bilder sofort auftauchen und die Liste
|
||
nicht im Code gepflegt werden muss. */
|
||
let BILDER = [
|
||
{ pfad: "./assets/screens/pipelines.png", name: "Pipelines · Board" },
|
||
{ pfad: "./assets/screens/pipelines-liste.png", name: "Pipelines · Liste" },
|
||
{ pfad: "./assets/screens/pipelines-detail.png", name: "Pipelines · Detail" },
|
||
{ pfad: "./assets/screens/workflow.png", name: "Workflow · Ablauf" },
|
||
{ pfad: "./assets/screens/workflow-bausteine.png", name: "Workflow · Bausteine" },
|
||
{ pfad: "./assets/screens/chat.png", name: "Chat" },
|
||
{ pfad: "./assets/screens/chat-tablet.png", name: "Chat · Tablet" },
|
||
{ pfad: "./assets/screens/chat-menue.png", name: "Chat · Menü" },
|
||
{ pfad: "./assets/screens/form-builder.png", name: "Form Builder" },
|
||
];
|
||
|
||
/* ─── Visualisierungen ───────────────────────────────────────────────────
|
||
Jede Slide nennt ueber "vis" ihr Bild. Gebaut wird es hier aus HTML, nicht
|
||
aus einer Aufnahme: so bleibt es beim Export scharf und laesst sich wie
|
||
jedes andere Element im Canvas verschieben. */
|
||
/* ─── Skizzen-Primitive ─────────────────────────────────────────────────────
|
||
Handzeichnung aus gesetztem Zufall. Der Keim ist fest: der Export rendert
|
||
bei fehlgeschlagener Pruefung bis zu fuenfmal neu, und jeder Durchgang muss
|
||
denselben Strich ergeben. Mit ungesetztem Math.random zittert jedes Bild
|
||
anders und die Kontrolle vergleicht gegen etwas, das es nie wieder gibt. */
|
||
let _s = 0;
|
||
function keim(n) { _s = n >>> 0; }
|
||
function rnd() {
|
||
_s = (_s + 0x6D2B79F5) >>> 0;
|
||
let t = Math.imul(_s ^ (_s >>> 15), 1 | _s);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
}
|
||
const zit = m => (rnd() * 2 - 1) * m;
|
||
const n1 = v => Math.round(v * 10) / 10;
|
||
|
||
/* Ein Strich, leicht gebogen und an den Enden verwackelt. Der Bogen waechst
|
||
mit der Laenge: eine kurze Kante zittert, eine lange schwingt. */
|
||
function strich(x1, y1, x2, y2, o) {
|
||
o = o || {};
|
||
const j = o.j == null ? 1.5 : o.j;
|
||
const ue = o.ue == null ? 0 : o.ue; // Ueberschuss an den Enden
|
||
let dx = x2 - x1, dy = y2 - y1;
|
||
const len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len;
|
||
const ax = x1 - ux * rnd() * ue + zit(j), ay = y1 - uy * rnd() * ue + zit(j);
|
||
const bx = x2 + ux * rnd() * ue + zit(j), by = y2 + uy * rnd() * ue + zit(j);
|
||
dx = bx - ax; dy = by - ay;
|
||
const nx = -dy / len, ny = dx / len;
|
||
const b = o.b == null ? len * 0.008 + 1 : o.b;
|
||
const o1 = zit(b), o2 = zit(b);
|
||
return "M" + n1(ax) + "," + n1(ay) + "C" +
|
||
n1(ax + dx / 3 + nx * o1) + "," + n1(ay + dy / 3 + ny * o1) + " " +
|
||
n1(ax + dx * 2 / 3 + nx * o2) + "," + n1(ay + dy * 2 / 3 + ny * o2) + " " +
|
||
n1(bx) + "," + n1(by);
|
||
}
|
||
|
||
/* Punktfolge zu einer weichen Kurve (Catmull-Rom als Bezier). */
|
||
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;
|
||
}
|
||
|
||
/* Kringel statt Kreis: der Zug faengt irgendwo an, laeuft einmal herum und
|
||
schiesst ein Stueck ueber den Anfang hinaus. Genau das macht die Hand. */
|
||
function kringel(cx, cy, rx, ry, o) {
|
||
o = o || {};
|
||
ry = ry == null ? rx : ry;
|
||
const wack = o.wack == null ? Math.min(rx, ry) * 0.02 + 1 : o.wack;
|
||
const a0 = o.a0 == null ? rnd() * 6.283 : o.a0;
|
||
const ueber = o.ueber == null ? 0.16 + rnd() * 0.2 : o.ueber;
|
||
const schritte = 18, pts = [];
|
||
const spanne = 6.283 + ueber;
|
||
for (let i = 0; i <= schritte; i++) {
|
||
const a = a0 + spanne * (i / schritte);
|
||
pts.push([cx + Math.cos(a) * (rx + zit(wack)), cy + Math.sin(a) * (ry + zit(wack))]);
|
||
}
|
||
return glatt(pts, false);
|
||
}
|
||
|
||
/* Vier einzelne Kanten mit Ueberschuss an den Ecken. Der Ueberschuss ist das
|
||
eigentliche Erkennungsmerkmal von Handzeichnung, nicht das Zittern. */
|
||
function rechteck(x, y, w, h, o) {
|
||
o = o || {};
|
||
const ue = o.ue == null ? 3.5 : o.ue;
|
||
return [
|
||
strich(x, y, x + w, y, { ue: ue }),
|
||
strich(x + w, y, x + w, y + h, { ue: ue }),
|
||
strich(x + w, y + h, x, y + h, { ue: ue }),
|
||
strich(x, y + h, x, y, { ue: ue }),
|
||
].join("");
|
||
}
|
||
|
||
/* Der Chamfer als gezeichneter Kasten: acht Kanten statt vier. Das ist die
|
||
Markenform aus rules.md Paragraf 3, hier von Hand gezogen. */
|
||
function chamfer(x, y, w, h, c, o) {
|
||
o = o || {};
|
||
const ue = o.ue == null ? 2.5 : o.ue;
|
||
const p = [[x + c, y], [x + w - c, y], [x + w, y + c], [x + w, y + h - c],
|
||
[x + w - c, y + h], [x + c, y + h], [x, y + h - c], [x, y + c]];
|
||
let d = "";
|
||
for (let i = 0; i < 8; i++) {
|
||
const a = p[i], b = p[(i + 1) % 8];
|
||
d += strich(a[0], a[1], b[0], b[1], { ue: i % 2 ? 1 : ue });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* Schraffur als Schattierung: parallele Striche, an den Enden kuerzer, damit
|
||
das Feld nicht als Block steht. */
|
||
function schraff(x, y, w, h, abstand, neig) {
|
||
neig = neig == null ? 0.55 : neig;
|
||
let d = "";
|
||
for (let t = -h * neig; t < w; t += abstand) {
|
||
const x1 = x + t, x2 = x + t + h * neig;
|
||
const a = Math.max(x, Math.min(x + w, x1)), b = Math.max(x, Math.min(x + w, x2));
|
||
if (b - a < 3) continue;
|
||
const y1 = y + h - (a - x1) / neig, y2 = y + h - (b - x1) / neig;
|
||
d += strich(a, Math.min(y + h, Math.max(y, y1)), b, Math.min(y + h, Math.max(y, y2)), { j: .8, b: .8 });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* Bodenschatten: ein paar kurze Striche unter dem Gegenstand. Sie sitzen eng
|
||
am Fuss, sonst schwebt der Gegenstand ueber seinem eigenen Schatten. */
|
||
function boden(cx, y, w) {
|
||
let d = "";
|
||
for (let i = 0; i < 4; i++) {
|
||
const f = 1 - Math.abs(i - 1.5) * 0.26;
|
||
const b = w * f * (0.62 + rnd() * 0.26);
|
||
d += strich(cx - b / 2 + zit(3), y + i * 3.6, cx + b / 2 + zit(3), y + i * 3.6 - 4, { j: .9, b: 1.2 });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* ─── Bausteine ────────────────────────────────────────────────────────── */
|
||
const TINTE = "#2A2C33", GELB = "#E9A81C";
|
||
const zug = (d, o) => {
|
||
o = o || {};
|
||
return '<path d="' + d + '" fill="none" stroke="' + (o.f || TINTE) +
|
||
'" stroke-width="' + (o.w || 3.1) + '" stroke-linecap="round" stroke-linejoin="round"' +
|
||
(o.op ? ' stroke-opacity="' + o.op + '"' : '') +
|
||
(o.strichel ? ' stroke-dasharray="' + o.strichel + '"' : '') + '/>';
|
||
};
|
||
const schatten = d => zug(d, { w: 1.9, op: .34 });
|
||
|
||
/* ─── Die Satelliten ─────────────────────────────────────────────────────
|
||
Jedes Ikon meldet zurueck, wie weit es reicht (rad) und wo seine
|
||
Beschriftung hingehoert (unten). Sonst haengt der Knoten am kleinen Ikon
|
||
zu weit weg und am grossen klebt er drin. */
|
||
function ikonKi(cx, cy) {
|
||
const zack = (x, y, r) => {
|
||
const k = r * 0.28, p = [];
|
||
for (let i = 0; i < 8; i++) {
|
||
const a = -Math.PI / 2 + i * Math.PI / 4, rr = i % 2 ? k : r;
|
||
p.push([x + Math.cos(a) * rr + zit(1.4), y + Math.sin(a) * rr + zit(1.4)]);
|
||
}
|
||
return glatt(p, true);
|
||
};
|
||
return zug(zack(cx - 8, cy - 6, 46), { w: 3.4 }) +
|
||
zug(zack(cx + 38, cy + 30, 17), { w: 2.7 }) +
|
||
zug(zack(cx + 34, cy - 34, 12), { w: 2.5 }) +
|
||
schatten(boden(cx, cy + 54, 88));
|
||
}
|
||
ikonKi.rad = 52; ikonKi.unten = 92;
|
||
|
||
function ikonChat(cx, cy) {
|
||
const blase = (x, y, rx, ry, sx) =>
|
||
zug(kringel(x, y, rx, ry, { ueber: .12 })) +
|
||
zug(glatt([[x + sx * rx * .42, y + ry * .82], [x + sx * rx * .86, y + ry * 1.6],
|
||
[x + sx * rx * .08, y + ry * .98]], false));
|
||
return blase(cx + 20, cy - 4, 38, 30, 1) +
|
||
blase(cx - 22, cy - 16, 47, 37, -1) +
|
||
[-18, 0, 18].map(o => zug(kringel(cx - 22 + o, cy - 16, 4.6), { w: 2.5 })).join("") +
|
||
schatten(boden(cx, cy + 52, 100));
|
||
}
|
||
ikonChat.rad = 58; ikonChat.unten = 90;
|
||
|
||
function ikonCrm(cx, cy) {
|
||
const kopf = (x, y, r, w) => zug(kringel(x, y, r), { w: w }) +
|
||
zug(glatt([[x - r * 1.55, y + r * 2.7], [x - r * 1.5, y + r * 1.05],
|
||
[x, y + r * .55], [x + r * 1.5, y + r * 1.05], [x + r * 1.55, y + r * 2.7]], false), { w: w });
|
||
return kopf(cx - 42, cy - 16, 17, 2.8) + kopf(cx + 42, cy - 16, 17, 2.8) +
|
||
kopf(cx, cy + 2, 22, 3.3) +
|
||
schatten(boden(cx, cy + 62, 116));
|
||
}
|
||
ikonCrm.rad = 72; ikonCrm.unten = 92;
|
||
|
||
function ikonAuto(cx, cy) {
|
||
const r = 39, zaehne = 10;
|
||
let d = "";
|
||
for (let i = 0; i < zaehne; i++) {
|
||
const a = i * 6.283 / zaehne, br = 0.22;
|
||
const p = [];
|
||
[[-br, r], [-br * .8, r + 14], [br * .8, r + 14], [br, r]].forEach(v => {
|
||
const aa = a + v[0];
|
||
p.push([cx + Math.cos(aa) * v[1] + zit(.9), cy + Math.sin(aa) * v[1] + zit(.9)]);
|
||
});
|
||
d += glatt(p, false);
|
||
}
|
||
return zug(kringel(cx, cy, r)) + zug(d, { w: 2.9 }) + zug(kringel(cx, cy, 15), { w: 2.7 }) +
|
||
schatten(boden(cx, cy + 60, 96));
|
||
}
|
||
ikonAuto.rad = 56; ikonAuto.unten = 88;
|
||
|
||
function ikonBoard(cx, cy) {
|
||
const w = 116, h = 88, x = cx - w / 2, y = cy - h / 2;
|
||
let d = rechteck(x, y, w, h);
|
||
let k = "";
|
||
for (let i = 1; i < 3; i++) k += strich(x + w * i / 3, y + 7, x + w * i / 3, y + h - 7, { j: 1, b: 1 });
|
||
const karte = (sx, sy, sh) => rechteck(x + sx, y + sy, w / 3 - 16, sh, { ue: 2 });
|
||
k += karte(8, 11, 19) + karte(8, 36, 19) + karte(w / 3 + 8, 11, 19) + karte(2 * w / 3 + 8, 11, 31);
|
||
return zug(d) + zug(k, { w: 2.4 }) + schatten(boden(cx, cy + h / 2 + 7, 116));
|
||
}
|
||
ikonBoard.rad = 64; ikonBoard.unten = 84;
|
||
|
||
function ikonFormular(cx, cy) {
|
||
const w = 90, h = 116, x = cx - w / 2, y = cy - h / 2;
|
||
let d = rechteck(x, y, w, h);
|
||
d += rechteck(x + w / 2 - 19, y - 12, 38, 22, { ue: 2 });
|
||
let k = "";
|
||
[32, 50, 68].forEach(o => { k += strich(x + 15, y + o, x + w - 34, y + o, { j: 1, b: 1 }); });
|
||
k += rechteck(x + 15, y + 80, 23, 23, { ue: 2 });
|
||
const hak = [[x + 19, y + 91], [x + 25, y + 98], [x + 35, y + 81]];
|
||
return zug(d) + zug(k, { w: 2.4 }) + zug(glatt(hak, false), { w: 3.6, f: GELB }) +
|
||
schatten(boden(cx, cy + h / 2 + 7, 92));
|
||
}
|
||
ikonFormular.rad = 62; ikonFormular.unten = 96;
|
||
|
||
/* Ein Linienzug aus Einzelstrichen. Jede Kante bekommt ihren eigenen Zug
|
||
mit Ueberschuss an den Enden, dadurch bleiben die Ecken kantig. Eine
|
||
durchgehende weiche Kurve wuerde eine Pfeilspitze zum Tropfen runden. */
|
||
function linienzug(pts, ue) {
|
||
let d = "";
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
d += strich(pts[i][0], pts[i][1], pts[i + 1][0], pts[i + 1][1],
|
||
{ ue: ue == null ? 3 : ue, j: 1.1 });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* Kritzelfuellung: schnelle Schraegstriche quer durch die Form, jeder von
|
||
Kante zu Kante. halb(x) gibt die halbe Hoehe an dieser Stelle, dadurch
|
||
laeuft die Fuellung in einer Spitze von selbst aus.
|
||
|
||
Der erste Versuch war ein durchgehendes Zickzack die Laenge entlang. In
|
||
der Spitze schlug es ueber die Kanten hinaus und ergab ein Knaeuel statt
|
||
einer Flaeche. Einzelne Striche bleiben innerhalb der Form, und wo einer
|
||
ausgelassen wird, bleibt Weiss stehen -- genau so malt man schnell aus. */
|
||
function kritzel(x1, x2, my, halb, abstand) {
|
||
let d = "";
|
||
for (let x = x1; x <= x2; x += abstand) {
|
||
const bis = Math.min(x2, x + 8);
|
||
const h1 = halb(x), h2 = halb(bis);
|
||
if (h1 < 4 || h2 < 3) continue;
|
||
if (rnd() < 0.12) continue; // ab und zu bleibt Weiss stehen
|
||
d += strich(x, my - h1 + 2, bis, my + h2 - 2, { j: 1, b: 1 });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* Ein Strich mit Spitze am Ende. Die Spitze sind zwei kurze Zuege, keine
|
||
gefuellte Flaeche: gezeichnet wird sie so, gefuellt sieht sie gesetzt aus. */
|
||
function pfeil(x1, y1, x2, y2, gr) {
|
||
gr = gr == null ? 16 : gr;
|
||
const a = Math.atan2(y2 - y1, x2 - x1), oeff = 0.42;
|
||
return strich(x1, y1, x2, y2, { j: 1.1 }) +
|
||
strich(x2, y2, x2 - Math.cos(a - oeff) * gr, y2 - Math.sin(a - oeff) * gr, { j: .8, b: .8 }) +
|
||
strich(x2, y2, x2 - Math.cos(a + oeff) * gr, y2 - Math.sin(a + oeff) * gr, { j: .8, b: .8 });
|
||
}
|
||
|
||
/* Schraffur innerhalb einer Wabe. Eine gezeichnete Flaeche wird nicht
|
||
gefuellt, sie wird schraffiert: so bleibt der Raster-Grund sichtbar und
|
||
es sieht trotzdem nach "voll" aus wie auf der Website. */
|
||
function wabeSchraff(cx, cy, r, abstand) {
|
||
const h = r * 0.866;
|
||
let d = "";
|
||
for (let y = -h + abstand; y < h - abstand * 0.5; y += abstand) {
|
||
const halb = r - (r / 2) * (Math.abs(y) / h) - 8;
|
||
if (halb < 7) continue;
|
||
d += strich(cx - halb, cy + y - 3, cx + halb, cy + y + 3, { j: .9, b: 1 });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
function ikonBrief(cx, cy, f) {
|
||
const w = 46, h = 31, x = cx - w / 2, y = cy - h / 2;
|
||
return zug(rechteck(x, y, w, h, { ue: 2.5 }), { w: 2.9, f: f }) +
|
||
zug(glatt([[x + 3, y + 4], [cx, y + h * 0.66], [x + w - 3, y + 4]], false),
|
||
{ w: 2.7, f: f });
|
||
}
|
||
|
||
function ikonBlaseKlein(cx, cy, f) {
|
||
return zug(kringel(cx, cy - 5, 25, 19, { ueber: .1 }), { w: 2.9, f: f }) +
|
||
zug(glatt([[cx - 8, cy + 12], [cx - 13, cy + 23], [cx + 3, cy + 13]], false),
|
||
{ w: 2.7, f: f }) +
|
||
[-9, 0, 9].map(o => zug(kringel(cx + o, cy - 5, 2.8), { w: 2.2, f: f })).join("");
|
||
}
|
||
|
||
function ikonKonfetti(cx, cy, f) {
|
||
// Knallbonbon: Tuete unten links, Funken nach oben rechts.
|
||
const tuete = glatt([[cx - 28, cy + 24], [cx - 6, cy - 2],
|
||
[cx + 10, cy + 19], [cx - 28, cy + 24]], false) +
|
||
strich(cx - 20, cy + 15, cx - 2, cy + 19, { j: .9, b: .8 });
|
||
let funken = "";
|
||
[-1.42, -1.06, -0.68, -0.28].forEach((a, i) => {
|
||
const r0 = 16, r1 = 30 + (i % 2) * 7;
|
||
funken += strich(cx + 2 + Math.cos(a) * r0, cy + 3 + Math.sin(a) * r0,
|
||
cx + 2 + Math.cos(a) * r1, cy + 3 + Math.sin(a) * r1, { j: 1 });
|
||
});
|
||
let punkte = "";
|
||
[[20, -27], [31, -11], [26, -35], [8, -33]].forEach(v => {
|
||
punkte += kringel(cx + v[0], cy + v[1], 3.1);
|
||
});
|
||
return zug(tuete, { w: 2.9, f: f }) + zug(funken, { w: 2.4, f: f }) +
|
||
zug(punkte, { w: 2.2, f: f });
|
||
}
|
||
|
||
/* Die Wabe, flat-top, Seitenverhaeltnis 2/Wurzel3 wie in rules.md Paragraf 3.
|
||
Bei dieser Form liegen die sechs Ecken auf einem Kreis vom Radius r. Genau
|
||
deshalb ist sie hier richtig: jede Speiche setzt an einer Ecke an, nicht
|
||
irgendwo auf einer Kante. Gezogen wird sie aus sechs einzelnen Strichen
|
||
mit Ueberschuss, nicht als geschlossenes Polygon. */
|
||
function wabeEcken(cx, cy, r) {
|
||
const p = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = i * Math.PI / 3;
|
||
p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
function wabe(cx, cy, r) {
|
||
const p = wabeEcken(cx, cy, r);
|
||
let d = "";
|
||
for (let i = 0; i < 6; i++) {
|
||
const q = p[i], w = p[(i + 1) % 6];
|
||
d += strich(q[0], q[1], w[0], w[1], { ue: 5, j: 1.8 });
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* ─── Die Mitte: das bestehende System ───────────────────────────────────
|
||
Bewusst als Liste gezeichnet, nicht als Kachel-Dashboard: so sieht eine
|
||
Warenwirtschaft aus. Und bewusst der schwerste Strich auf der Slide, denn
|
||
sie ist das, was bleibt. Das Fenster passt ganz in den Ring, sonst kreuzen
|
||
sich Ringbogen und Fensterecke, und weil der Grund durchscheinen soll gibt
|
||
es keine Fuellung, die das verdecken koennte. */
|
||
function nabe(cx, cy) {
|
||
const w = 216, h = 150, x = cx - w / 2, y = cy - h / 2, kopf = 40;
|
||
let d = rechteck(x, y, w, h, { ue: 4 });
|
||
d += strich(x, y + kopf, x + w, y + kopf, { ue: 3 });
|
||
let k = "";
|
||
[0, 1, 2].forEach(i => { k += kringel(x + 18 + i * 15, y + kopf / 2, 4.4, 4.4, { ueber: .1 }); });
|
||
let z = "";
|
||
for (let i = 0; i < 4; i++) {
|
||
const yy = y + kopf + 22 + i * 23;
|
||
z += strich(x + 17, yy, x + 59, yy, { j: 1, b: 1 });
|
||
z += strich(x + 72, yy, x + w - 48, yy, { j: 1, b: 1 });
|
||
z += strich(x + w - 38, yy, x + w - 17, yy, { j: 1, b: 1 });
|
||
}
|
||
return zug(d, { w: 3.6 }) + zug(k, { w: 2.4 }) + zug(z, { w: 2.2, op: .8 }) +
|
||
'<text x="' + cx + '" y="' + (y + kopf / 2 + 8) + '" text-anchor="middle" ' +
|
||
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" ' +
|
||
'letter-spacing="-0.01em" fill="' + TINTE + '">HiveGrid</text>';
|
||
}
|
||
|
||
/* ─── Aufbau ───────────────────────────────────────────────────────────── */
|
||
/* ─── Slide 2: nur so gewachsen ──────────────────────────────────────────
|
||
Ein Knaeuel aus Kanaelen, durch das sich eine gestrichelte Leitung windet.
|
||
Bewusst ohne Aufloesung: das Gegenbild dazu ist Slide 5, und die Pointe
|
||
soll nicht zweimal vorkommen.
|
||
Die Namen sind allgemein gehalten, keine fremden Produkte. */
|
||
function glyBrief(cx, cy) {
|
||
const w = 30, h = 21, x = cx - w / 2, y = cy - h / 2;
|
||
return rechteck(x, y, w, h, { ue: 1.6 }) +
|
||
glatt([[x + 2, y + 3], [cx, y + h * 0.68], [x + w - 2, y + 3]], false);
|
||
}
|
||
function glyZettel(cx, cy) {
|
||
const w = 26, h = 32, x = cx - w / 2, y = cy - h / 2, e = 11;
|
||
return glatt([[x, y], [x + w - e, y], [x + w, y + e], [x + w, y + h],
|
||
[x, y + h], [x, y]], false) +
|
||
glatt([[x + w - e, y], [x + w - e, y + e], [x + w, y + e]], false);
|
||
}
|
||
function glyTabelle(cx, cy) {
|
||
const w = 30, h = 24, x = cx - w / 2, y = cy - h / 2;
|
||
let d = rechteck(x, y, w, h, { ue: 1.6 });
|
||
d += strich(x, y + h / 3, x + w, y + h / 3, { j: .7, b: .6 });
|
||
d += strich(x, y + h * 2 / 3, x + w, y + h * 2 / 3, { j: .7, b: .6 });
|
||
d += strich(x + w / 3, y, x + w / 3, y + h, { j: .7, b: .6 });
|
||
return d;
|
||
}
|
||
function glyHandy(cx, cy) {
|
||
const w = 20, h = 32, x = cx - w / 2, y = cy - h / 2;
|
||
return rechteck(x, y, w, h, { ue: 1.4 }) +
|
||
strich(x + 6, y + 4, x + w - 6, y + 4, { j: .6, b: .5 }) +
|
||
kringel(cx, y + h - 5, 1.9);
|
||
}
|
||
function glyOrdner(cx, cy) {
|
||
const w = 30, h = 26, x = cx - w / 2, y = cy - h / 2;
|
||
return rechteck(x, y + 6, w, h - 6, { ue: 1.4 }) +
|
||
glatt([[x + 1, y + 6], [x + 3, y], [x + 13, y], [x + 15, y + 6]], false);
|
||
}
|
||
|
||
/* Die kleinen Kacheln, die im Vorbild lose dazwischen schweben. */
|
||
function kachel(cx, cy, art, gr) {
|
||
const s = gr || 52, x = cx - s / 2, y = cy - s / 2;
|
||
let d = rechteck(x, y, s, s, { ue: 2.5 });
|
||
const f = s / 52;
|
||
if (art === "blatt") {
|
||
d += rechteck(x + 16 * f, y + 13 * f, 20 * f, 26 * f, { ue: 1.4 });
|
||
[20, 26, 32].forEach(o => {
|
||
d += strich(x + 20 * f, y + o * f, x + 32 * f, y + o * f, { j: .6, b: .5 });
|
||
});
|
||
} else if (art === "bild") {
|
||
d += rechteck(x + 13 * f, y + 15 * f, 26 * f, 22 * f, { ue: 1.4 });
|
||
d += glatt([[x + 16 * f, y + 33 * f], [x + 23 * f, y + 23 * f], [x + 29 * f, y + 30 * f],
|
||
[x + 32 * f, y + 26 * f], [x + 36 * f, y + 33 * f]], false);
|
||
} else {
|
||
d += kringel(cx, y + 19 * f, 6 * f);
|
||
d += glatt([[x + 14 * f, y + 40 * f], [x + 15 * f, y + 32 * f], [cx, y + 28 * f],
|
||
[x + s - 15 * f, y + 32 * f], [x + s - 14 * f, y + 40 * f]], false);
|
||
}
|
||
return d;
|
||
}
|
||
|
||
/* Eine ausholende Schlaufe zwischen zwei Ankern. Sie bauscht immer von der
|
||
Kachelspalte weg, damit die Leitung keine Beschriftung kreuzt: auf einem
|
||
durchscheinenden Grund kann kein Kasten sie verdecken. */
|
||
function schlaufe(ax, ay, bx, by, weite, seite) {
|
||
const dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
|
||
const nx = (dy / l) * seite, ny = (-dx / l) * seite;
|
||
return [
|
||
[ax, ay],
|
||
[ax + nx * weite * 0.62 + dx * 0.06, ay + ny * weite * 0.62 + dy * 0.06],
|
||
[(ax + bx) / 2 + nx * weite, (ay + by) / 2 + ny * weite],
|
||
[bx + nx * weite * 0.62 - dx * 0.06, by + ny * weite * 0.62 - dy * 0.06],
|
||
[bx, by],
|
||
];
|
||
}
|
||
|
||
/* Ein Stueck Leitung von Kasten zu Kasten.
|
||
|
||
Der erste Versuch legte die Stuetzpunkte direkt auf die Kastenkante und
|
||
liess die Bahn danach irgendwohin laufen. Das sah aus, als streife die
|
||
Linie den Kasten zufaellig, und wo zwei Punkte fast aufeinanderlagen,
|
||
warf die Kurve einen Zipfel. Beides zusammen war der Grund, warum die
|
||
Anbindung nicht stimmte.
|
||
|
||
Jetzt bekommt jedes Stueck vor dem Kasten einen Punkt auf gleicher Hoehe,
|
||
52 px daneben. Die Kurve laeuft dadurch waagerecht aus dem Kasten heraus
|
||
und waagerecht in den naechsten hinein: sie haengt sichtbar an ihm, statt
|
||
an ihm vorbeizukommen. */
|
||
function leitungsStueck(vonX, vonY, nachX, nachY, seite, abstand, zwischen) {
|
||
return [[vonX, vonY], [vonX + seite * abstand, vonY]]
|
||
.concat(zwischen)
|
||
.concat([[nachX + seite * abstand, nachY], [nachX, nachY]]);
|
||
}
|
||
|
||
function visProzesse() {
|
||
keim(20260813);
|
||
const W = 896, H = 730, HOCH = 62;
|
||
|
||
/* Die x-Werte sind so gewaehlt, dass die rechten Kanten dort landen, wo
|
||
die Leitung sie erwartet. Wer eine Beschriftung aendert, aendert die
|
||
Kastenbreite und muss die Ankerpunkte unten mitziehen. */
|
||
const chips = [
|
||
{ x: 34, y: 60, t: "E-Mails", g: glyBrief },
|
||
{ x: 104, y: 200, t: "Zettel im Auto", g: glyZettel },
|
||
{ x: 48, y: 340, t: "Tabellen", g: glyTabelle },
|
||
{ x: 128, y: 480, t: "Fotos auf dem Handy", g: glyHandy },
|
||
{ x: 60, y: 620, t: "Ordner im Regal", g: glyOrdner },
|
||
];
|
||
chips.forEach(c => {
|
||
c.w = Math.round(84 + c.t.length * 14.2);
|
||
c.r = c.x + c.w;
|
||
});
|
||
|
||
let raus = "";
|
||
|
||
// Rechte Kette: von rechter Kante zu rechter Kante, mit weiten Bogen.
|
||
const rechts = [
|
||
[[470, 34], [590, 72], [624, 150], [540, 186]],
|
||
[[576, 244], [672, 312], [604, 392], [492, 372], [432, 308]],
|
||
[[352, 398], [452, 424], [600, 406], [688, 470]],
|
||
[[664, 540], [708, 624], [606, 684], [478, 660]],
|
||
];
|
||
let leitung = "";
|
||
rechts.forEach((zw, i) => {
|
||
leitung += glatt(leitungsStueck(chips[i].r, chips[i].y, chips[i + 1].r,
|
||
chips[i + 1].y, 1, 52, zw)
|
||
.map(q => [q[0] + zit(2.5), q[1] + zit(2.5)]), false);
|
||
});
|
||
// Linke Kette: dort ist wenig Platz, also zwei knappe Bogen.
|
||
[[1, 2, [[24, 244], [14, 302]]], [3, 4, [[22, 522], [16, 576]]]].forEach(v => {
|
||
leitung += glatt(leitungsStueck(chips[v[0]].x, chips[v[0]].y, chips[v[1]].x,
|
||
chips[v[1]].y, -1, 34, v[2])
|
||
.map(q => [q[0] + zit(2), q[1] + zit(2)]), false);
|
||
});
|
||
/* Die Leitung traegt den Akzent. Gestrichelt und gelb heisst hier
|
||
gewachsen, durchgezogen und gelb auf Slide 5 heisst verbunden: dieselbe
|
||
Farbe, aber die Strichart trennt die beiden Aussagen. Etwas kraeftiger
|
||
als in Grau, weil eine unterbrochene Linie weniger Flaeche hat. */
|
||
raus += zug(leitung, { f: GELB, w: 2.9, op: .78, strichel: "11 13" });
|
||
|
||
[[700, 96, "blatt", 52], [770, 236, "bild", 46], [760, 430, "person", 50],
|
||
[250, 690, "blatt", 44]]
|
||
.forEach(k => { raus += zug(kachel(k[0], k[1], k[2], k[3]), { w: 2.5, op: .8 }); });
|
||
|
||
chips.forEach(c => {
|
||
const y = c.y - HOCH / 2;
|
||
raus += zug(chamfer(c.x, y, c.w, HOCH, 13), { w: 3.4 });
|
||
raus += zug(c.g(c.x + 34, c.y), { w: 2.6 });
|
||
raus += '<text x="' + (c.x + 64) + '" y="' + (c.y + 10) + '" ' +
|
||
'font-family="Manrope, sans-serif" font-size="27" font-weight="600" ' +
|
||
'letter-spacing="-0.01em" fill="' + TINTE + '">' + c.t + '</text>';
|
||
});
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + raus + '</svg>';
|
||
}
|
||
|
||
/* ─── Slide 1: Foto im gezeichneten Rahmen ───────────────────────────────
|
||
Das Foto bleibt roh, gezeichnet wird nur drumherum. Der Helm sitzt wie
|
||
die Krone beim Zeichenspiel auf der oberen rechten Ecke, angeschraegt und
|
||
halb ueber der Kante.
|
||
|
||
Er ist ganz in Gelb gezogen, Umriss wie Schraffur. Das hat einen Grund:
|
||
er liegt zur Haelfte auf dem hellen Grund und zur Haelfte auf dem dunklen
|
||
Foto. Eine Tintenlinie waere auf dem Foto verschwunden. Gelb traegt auf
|
||
beidem, und ein Bauhelm ist ohnehin gelb. */
|
||
/* ─── Randnotizen ────────────────────────────────────────────────────────
|
||
Kleine Zeichen neben dem Foto, wie am Rand eines Blocks hingekritzelt.
|
||
Sie stehen ausserhalb des Rahmens, damit sie das Bild nicht stoeren, und
|
||
bleiben in Tinte -- nur was leuchtet, ist gelb. */
|
||
function gluehbirne(cx, cy, gr) {
|
||
const r = gr * 0.34, my = cy - gr * 0.12;
|
||
const kolben = kringel(cx, my, r, r * 1.06, { ueber: .12 });
|
||
const hals = strich(cx - r * 0.52, my + r * 0.9, cx - r * 0.46, my + r * 1.3, { j: .7 }) +
|
||
strich(cx + r * 0.52, my + r * 0.9, cx + r * 0.46, my + r * 1.3, { j: .7 });
|
||
const sockel = rechteck(cx - r * 0.46, my + r * 1.28, r * 0.92, r * 0.6, { ue: 1.5 }) +
|
||
strich(cx - r * 0.44, my + r * 1.5, cx + r * 0.44, my + r * 1.5, { j: .5, b: .5 });
|
||
const faden = glatt([[cx - r * 0.34, my - r * 0.08], [cx - r * 0.12, my + r * 0.3],
|
||
[cx, my - r * 0.14], [cx + r * 0.12, my + r * 0.3],
|
||
[cx + r * 0.34, my - r * 0.08]], false);
|
||
let strahlen = "";
|
||
[-2.55, -2.0, -1.45, -0.9, -0.35].forEach(a => {
|
||
strahlen += strich(cx + Math.cos(a) * r * 1.32, my + Math.sin(a) * r * 1.32,
|
||
cx + Math.cos(a) * r * 1.8, my + Math.sin(a) * r * 1.8, { j: .8 });
|
||
});
|
||
return zug(strahlen, { f: GELB, w: 3 }) + zug(kolben, { w: 3.2 }) +
|
||
zug(hals, { w: 2.6 }) + zug(sockel, { w: 2.6 }) + zug(faden, { w: 2, op: .65 });
|
||
}
|
||
|
||
/* Ein Zettel mit Eselsohr und zwei gekritzelten Zeilen. */
|
||
function zettel(cx, cy, w, h) {
|
||
const x = cx - w / 2, y = cy - h / 2, e = w * 0.32;
|
||
const blatt = glatt([[x, y], [x + w - e, y], [x + w, y + e], [x + w, y + h],
|
||
[x, y + h], [x, y]], false);
|
||
const knick = glatt([[x + w - e, y], [x + w - e + 4, y + e - 4], [x + w, y + e]], false);
|
||
let zeilen = "";
|
||
[0.44, 0.6, 0.76].forEach((f, i) => {
|
||
zeilen += strich(x + w * 0.15, y + h * f, x + w * (i === 2 ? 0.58 : 0.82), y + h * f,
|
||
{ j: .8, b: .8 });
|
||
});
|
||
return zug(blatt, { w: 2.8 }) + zug(knick, { w: 2.2, op: .7 }) +
|
||
zug(zeilen, { w: 2, op: .72 });
|
||
}
|
||
|
||
/* Ringmaulschluessel.
|
||
|
||
Zwei Anlaeufe liegen dahinter. Erst war das Maul mit glatt() gezogen, und
|
||
die weiche Kurve rundete die Kanten zu einem Knochen ab. Dann war es zwar
|
||
kantig, aber immer noch ein Umriss ohne Werkzeugcharakter.
|
||
|
||
Was ihn ausmacht, sind drei Dinge, die ein echter Schluessel hat: die
|
||
Backen stehen schraeg zur Schaftachse, der Ringkopf ist ein angestelltes
|
||
Oval mit Sechskant darin, und der Schaft ist zur Mitte hin schlanker. */
|
||
function schluessel(L) {
|
||
const t = L * 0.11;
|
||
|
||
// Schaft, zur Mitte verjuengt.
|
||
const schaft = glatt([[-L * 0.30, -t * 0.66], [-L * 0.05, -t * 0.46],
|
||
[L * 0.22, -t * 0.6]], false) +
|
||
glatt([[-L * 0.30, t * 0.66], [-L * 0.05, t * 0.46],
|
||
[L * 0.22, t * 0.6]], false);
|
||
|
||
// Ringkopf: Oval mit Sechskant, um 12 Grad angestellt.
|
||
const rx = -L * 0.42;
|
||
const kopf = kringel(rx, 0, t * 1.95, t * 1.62, { ueber: .12 });
|
||
const sechskant = wabe(rx, 0, t * 0.95);
|
||
|
||
// Maul: zwei Backen, um 16 Grad zur Schaftachse gedreht.
|
||
const eck = [[0.22, -0.6], [0.29, -1.95], [0.52, -1.95], [0.52, -1.0],
|
||
[0.35, -0.44], [0.35, 0.44], [0.52, 1.0], [0.52, 1.95],
|
||
[0.29, 1.95], [0.22, 0.6]];
|
||
let maul = "";
|
||
for (let i = 0; i < eck.length - 1; i++) {
|
||
maul += strich(L * eck[i][0], t * eck[i][1],
|
||
L * eck[i + 1][0], t * eck[i + 1][1], { j: .55, b: .5 });
|
||
}
|
||
|
||
// Zwei kurze Zuege am Schaft, sonst wirkt er wie ein flacher Streifen.
|
||
const glanz = strich(-L * 0.16, t * 0.24, L * 0.02, t * 0.16, { j: .5, b: .5 }) +
|
||
strich(L * 0.06, t * 0.2, L * 0.16, t * 0.26, { j: .5, b: .5 });
|
||
|
||
return zug(schaft, { w: 2.8 }) +
|
||
'<g transform="rotate(-12 ' + n1(rx) + ' 0)">' +
|
||
zug(kopf, { w: 2.8 }) + zug(sechskant, { w: 2.2, op: .75 }) +
|
||
'</g>' +
|
||
'<g transform="rotate(16 ' + n1(L * 0.22) + ' 0)">' + zug(maul, { w: 2.8 }) + '</g>' +
|
||
zug(glanz, { w: 1.8, op: .5 });
|
||
}
|
||
|
||
/* Der Helm. Drei Anlaeufe stecken drin, und die Zwischenschritte stehen
|
||
hier, weil sie lehrreich sind: erst war die Kuppel flach und die Krempe
|
||
eine geschlossene Scheibe darunter, zusammen eine Schirmmuetze. Dann kam
|
||
Schraffur dazu, und die Waagerechten kreuzten die Grate zu einem Gitter,
|
||
also einem Korb. Was traegt, sind viele Stuetzpunkte in der Kuppel, ein
|
||
First von vorn nach hinten, zwei Nebenrippen und eine Krempe mit Dicke.
|
||
|
||
Alles in Gelb, Umriss wie Rippen: der Helm liegt je nach Lage auf hellem
|
||
Grund oder auf dem dunklen Foto. Eine Tintenlinie waere dort verschwunden,
|
||
und ein Bauhelm ist ohnehin gelb. */
|
||
function bauhelm(breite) {
|
||
const b = breite / 2, kd = b * 0.76, h = b * 1.0;
|
||
|
||
const kuppel = glatt([
|
||
[-kd, 2], [-kd * 1.03, -h * 0.28], [-kd * 0.93, -h * 0.58],
|
||
[-kd * 0.62, -h * 0.86], [-kd * 0.22, -h * 0.99], [kd * 0.22, -h * 0.99],
|
||
[kd * 0.62, -h * 0.86], [kd * 0.93, -h * 0.58], [kd * 1.03, -h * 0.28], [kd, 2],
|
||
], false);
|
||
|
||
const first = glatt([[-kd * 0.04, -h * 0.99], [0, -h * 0.62],
|
||
[kd * 0.03, -h * 0.22], [kd * 0.04, 1]], false);
|
||
let rippen = "";
|
||
[-1, 1].forEach(v => {
|
||
rippen += glatt([[v * kd * 0.36, -h * 0.92], [v * kd * 0.58, -h * 0.56],
|
||
[v * kd * 0.68, -h * 0.17], [v * kd * 0.7, 1]], false);
|
||
});
|
||
|
||
// Krempe: unten und oben ein Zug, dadurch bekommt sie Dicke.
|
||
const krempeU = glatt([[-b, 4], [-b * 0.62, 16], [0, 21], [b * 0.62, 16], [b, 4]], false);
|
||
const krempeO = glatt([[-b, 4], [-b * 0.62, 7], [0, 10], [b * 0.62, 7], [b, 4]], false);
|
||
|
||
// Schattierung links: vier Boegen parallel zur Aussenkante.
|
||
let schatten = "";
|
||
for (let i = 0; i < 4; i++) {
|
||
const f = 0.94 - i * 0.1;
|
||
schatten += glatt([[-kd * f, -h * 0.16], [-kd * f * 0.94, -h * 0.46],
|
||
[-kd * f * 0.7, -h * 0.72]], false);
|
||
}
|
||
|
||
return zug(schatten, { f: GELB, w: 2.1, op: .45 }) +
|
||
zug(kuppel, { f: GELB, w: 5 }) +
|
||
zug(first, { f: GELB, w: 3, op: .8 }) +
|
||
zug(rippen, { f: GELB, w: 2.3, op: .55 }) +
|
||
zug(krempeU, { f: GELB, w: 5 }) +
|
||
zug(krempeO, { f: GELB, w: 3.4, op: .85 });
|
||
}
|
||
|
||
/* Zwei Lagen, umschaltbar ueber den Layout-Schalter der Slide.
|
||
"ecke": der Helm haengt an der abgeschnittenen Rahmenecke. Er kippt dafuer
|
||
nach rechts, damit die Oeffnung ueber der Ecke liegt und nicht von ihr
|
||
weg zeigt. Nach links gekippt sah er aus, als falle er herunter.
|
||
"kopf": er sitzt auf Noahs Stirn, groesser, damit er getragen wirkt und
|
||
nicht wie eine Kappe aufliegt. */
|
||
function visKarte(lage) {
|
||
keim(20260811);
|
||
const W = 950, H = 1095;
|
||
/* Das Foto liegt in dieser Ebene bei x 150..770 und y 180..955, der Rahmen
|
||
16 px aussen herum. Rundherum bleibt Platz: rechts fuer den Helm in der
|
||
Ecken-Lage, links und unten fuer die Randnotizen. */
|
||
let band = "";
|
||
for (let y = 208; y < 958; y += 13) band += strich(787, y, 803, y + 16, { j: .7, b: .7 });
|
||
for (let x = 176; x < 770; x += 13) band += strich(x, 972, x + 16, 988, { j: .7, b: .7 });
|
||
|
||
/* Drei Sachen greifen ineinander statt nebeneinanderher zu laufen: der
|
||
versetzte Zug ist die Aussenkante des Schattens, die Schraffur fuellt
|
||
das Band dazwischen, und beides folgt der Markenform.
|
||
|
||
Zur Schattenfrage: rules.md Paragraf 5 verbietet Schatten, meint damit
|
||
aber weichgezeichnete. Die dort notierte Ausnahme sagt, eine Linie ist
|
||
kein Schatten. Eine Schraffur besteht aus Linien. */
|
||
const rahmen =
|
||
zug(chamfer(152, 182, 652, 807, 30, { ue: 5 }), { w: 3, op: .38 }) +
|
||
zug(band, { w: 1.9, op: .34 }) +
|
||
zug(chamfer(134, 164, 652, 807, 30, { ue: 9 }), { w: 6 });
|
||
|
||
/* Randnotizen. Der erste Versuch waren Sternchen und eine Wellenlinie --
|
||
huebsch, aber ohne jeden Bezug zum Beitrag. Jetzt steht dort, was in
|
||
einem SHK-Betrieb herumliegt: die Idee, ein Zettel, Werkzeug, eine
|
||
Wasserwaage. */
|
||
/* Nach innen gerueckt: das Profil-Raster schneidet die Slide auf 3:4 und
|
||
nimmt links und rechts je rund 34 px weg. Slide 1 ist das Titelbild und
|
||
damit genau die, die dort erscheint. */
|
||
const rand =
|
||
gluehbirne(72, 300, 98) +
|
||
'<g transform="translate(74 676) rotate(-34)">' + schluessel(118) + '</g>' +
|
||
'<g transform="translate(852 636) rotate(7)">' + zettel(0, 0, 82, 100) + '</g>';
|
||
|
||
const helm = lage === "kopf"
|
||
? '<g transform="translate(502 380) rotate(-4)">' + bauhelm(222) + '</g>'
|
||
: '<g transform="translate(794 198) rotate(28)">' + bauhelm(196) + '</g>';
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg" class="karte-skizze">' +
|
||
rand + rahmen + helm + '</svg>';
|
||
}
|
||
|
||
/* ─── Slide 3: wechseln kannst du vergessen ──────────────────────────────
|
||
Eine Leserichtung statt einer Anordnung: Wechsel-Symbol, Pfeil, Chaos.
|
||
Was drinhaengt, steht im Symbol; wohin es fuehrt, steht rechts.
|
||
|
||
Vier Fassungen liegen dahinter. Ein verwurzelter Kasten mit gerissener
|
||
Leine und eine beladene Sackkarre erzaehlten je eine kleine Szene. Eine
|
||
dritte reihte Block, Pfeile und eine schraffierte Wand nebeneinander, und
|
||
die Wand las niemand als Wand. Die vierte war ein durchgestrichenes
|
||
Symbol, aber der Strich lief quer durch die Woerter.
|
||
|
||
Was jede von ihnen nicht hatte: eine Richtung. Drei Stationen von links
|
||
nach rechts liest man in einer Sekunde, drei Teile nebeneinander nicht. */
|
||
function visWechseln() {
|
||
keim(20260815);
|
||
/* Die Flaeche ist niedriger als bei den anderen Slides: drei Formen
|
||
nebeneinander duerfen in 896 Breite hoechstens rund 200 Radius haben,
|
||
und eine 730 hohe Box waere dann zu drei Vierteln leer. Sie sitzt
|
||
stattdessen mittig in der freien Hoehe unter dem Text. */
|
||
const W = 896, H = 520, MY = 258;
|
||
const ZX = 200, RX = 190, RY = 200;
|
||
const zeilen = ["Stammdaten", "Aufträge", "Buchhaltung"];
|
||
|
||
/* Das Wechsel-Symbol: zwei Boegen mit Luecken links und rechts. Ein
|
||
geschlossener Ring waere kein Symbol mehr, sondern eine Ellipse. */
|
||
function bogen(a1, a2) {
|
||
const n = 18, p = [];
|
||
for (let i = 0; i <= n; i++) {
|
||
const a = (a1 + (a2 - a1) * i / n) * Math.PI / 180;
|
||
p.push([ZX + Math.cos(a) * RX + zit(1.8), MY + Math.sin(a) * RY + zit(1.8)]);
|
||
}
|
||
return p;
|
||
}
|
||
function mitSpitze(p, laenge) {
|
||
const e = p[p.length - 1], v = p[p.length - 2];
|
||
const w = Math.atan2(e[1] - v[1], e[0] - v[0]);
|
||
let d = glatt(p, false);
|
||
[-0.44, 0.44].forEach(o => {
|
||
d += strich(e[0], e[1], e[0] - Math.cos(w + o) * laenge,
|
||
e[1] - Math.sin(w + o) * laenge, { j: .9, b: .9 });
|
||
});
|
||
return d;
|
||
}
|
||
|
||
let raus = zug(mitSpitze(bogen(204, 336), 30) +
|
||
mitSpitze(bogen(24, 156), 30), { w: 7 });
|
||
zeilen.forEach((t, i) => {
|
||
raus += '<text x="' + ZX + '" y="' + (MY - 34 + i * 40) + '" text-anchor="middle" ' +
|
||
'font-family="Manrope, sans-serif" font-size="30" font-weight="700" ' +
|
||
'letter-spacing="-0.02em" fill="' + TINTE + '">' + t + '</text>';
|
||
});
|
||
|
||
/* Der Pfeil dazwischen, gebaut wie die anderen im Beitrag: Umriss
|
||
gezeichnet, innen mit Schraegstrichen ausgemalt. */
|
||
const px1 = 420, pkopf = 500, pspitze = 548;
|
||
const pHalb = x => x <= pkopf ? 19 : Math.max(2, 36 * (1 - (x - pkopf) / (pspitze - pkopf)));
|
||
raus += zug(kritzel(px1 + 4, pspitze - 6, MY, pHalb, 11), { w: 4.2, op: .92 });
|
||
raus += zug(linienzug([[px1, MY - 19], [px1 + 40, MY - 21], [pkopf, MY - 19],
|
||
[pkopf, MY - 36], [pspitze, MY], [pkopf, MY + 36],
|
||
[pkopf, MY + 19], [px1 + 40, MY + 21], [px1, MY + 19],
|
||
[px1, MY - 19]]), { w: 3.4 });
|
||
|
||
/* Das Chaos: eine Bahn durch lauter zufaellige Punkte, weich gezogen. Sie
|
||
kreuzt sich dadurch staendig selbst. Zwei Durchgaenge, damit es dicht
|
||
wird, dazu ein paar lose Enden nach aussen -- ein Knaeuel ohne Enden
|
||
sieht aus wie eine Wolke. */
|
||
function knaeuel(cx, cy, rx, ry, n) {
|
||
const p = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const a = rnd() * 6.283, r = 0.18 + rnd() * 0.82;
|
||
p.push([cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r]);
|
||
}
|
||
return glatt(p, false);
|
||
}
|
||
const CX = 726, CRX = 162, CRY = 196;
|
||
raus += zug(knaeuel(CX, MY, CRX, CRY, 26), { w: 3.4 });
|
||
raus += zug(knaeuel(CX, MY, CRX * 0.78, CRY * 0.78, 20), { w: 2.8, op: .85 });
|
||
let enden = "";
|
||
[[-0.7, 1], [0.4, 1], [2.3, 1], [3.6, 1], [4.9, 1]].forEach(v => {
|
||
const a = v[0];
|
||
enden += glatt([[CX + Math.cos(a) * CRX * 0.9, MY + Math.sin(a) * CRY * 0.9],
|
||
[CX + Math.cos(a) * CRX * 1.12 + zit(10),
|
||
MY + Math.sin(a) * CRY * 1.12 + zit(10)],
|
||
[CX + Math.cos(a) * CRX * 1.3 + zit(14),
|
||
MY + Math.sin(a) * CRY * 1.3 + zit(14)]], false);
|
||
});
|
||
raus += zug(enden, { w: 2.6, op: .8 });
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + raus + '</svg>';
|
||
}
|
||
|
||
/* ─── Slide 4: wir wollten mehr ──────────────────────────────────────────
|
||
Die alte Fassung zeigte vier Kaesten mit "fehlt". Das ist der Mangel, die
|
||
Slide handelt aber vom Wunsch. Also ein Blatt mit einer Liste.
|
||
|
||
Die Punkte nennen nicht noch einmal KI und Automatisierung, das steht
|
||
schon im Fliesstext darueber. Sie sagen dasselbe in den Worten eines
|
||
Betriebs. Die Kaestchen bleiben leer: erfuellt wird der Wunsch erst auf
|
||
Slide 5, und genau davon lebt der Uebergang.
|
||
|
||
Gelb tragen die Kaestchen. Es ist der einzige Akzent hier und bleibt damit
|
||
bei seiner Rolle aus den anderen Slides: Struktur, keine Verzierung. */
|
||
/* Kleine Zeichen fuer die Liste auf Slide 4. Sie liegen in derselben
|
||
Groesse wie die Glyphen der Kanaele auf Slide 2, damit beide Listen
|
||
dieselbe Sprache sprechen. Alle geben Pfaddaten zurueck, keine Markup. */
|
||
function glyZahnrad(cx, cy) {
|
||
const r = 14, n = 8;
|
||
let d = "";
|
||
for (let i = 0; i < n; i++) {
|
||
const a = i * 6.283 / n, br = 0.24, pk = [];
|
||
[[-br, r], [-br * .8, r + 7], [br * .8, r + 7], [br, r]].forEach(v => {
|
||
const aa = a + v[0];
|
||
pk.push([cx + Math.cos(aa) * v[1] + zit(.6), cy + Math.sin(aa) * v[1] + zit(.6)]);
|
||
});
|
||
d += glatt(pk, false);
|
||
}
|
||
return kringel(cx, cy, r) + d + kringel(cx, cy, 5.5);
|
||
}
|
||
|
||
function glyBoard(cx, cy) {
|
||
const w = 44, h = 34, x = cx - w / 2, y = cy - h / 2;
|
||
let d = rechteck(x, y, w, h, { ue: 1.6 });
|
||
for (let i = 1; i < 3; i++) d += strich(x + w * i / 3, y + 5, x + w * i / 3, y + h - 5, { j: .6, b: .6 });
|
||
d += rechteck(x + 4, y + 6, w / 3 - 8, 8, { ue: 1 }) +
|
||
rechteck(x + 4, y + 19, w / 3 - 8, 8, { ue: 1 }) +
|
||
rechteck(x + w / 3 + 4, y + 6, w / 3 - 8, 8, { ue: 1 });
|
||
return d;
|
||
}
|
||
|
||
function glyBlase(cx, cy) {
|
||
return kringel(cx, cy - 3, 20, 15, { ueber: .1 }) +
|
||
glatt([[cx - 6, cy + 9], [cx - 10, cy + 19], [cx + 4, cy + 10]], false) +
|
||
[-7, 0, 7].map(o => kringel(cx + o, cy - 3, 2.4)).join("");
|
||
}
|
||
|
||
function glyLeute(cx, cy) {
|
||
const kopf = (x, y, r) => kringel(x, y, r) +
|
||
glatt([[x - r * 1.5, y + r * 2.6], [x - r * 1.45, y + r * 1.05], [x, y + r * .55],
|
||
[x + r * 1.45, y + r * 1.05], [x + r * 1.5, y + r * 2.6]], false);
|
||
return kopf(cx - 15, cy - 5, 6.5) + kopf(cx + 15, cy - 5, 6.5) + kopf(cx, cy + 1, 8.5);
|
||
}
|
||
|
||
function visMehr() {
|
||
keim(20260816);
|
||
/* 988 statt 896 breit: die Flaeche reicht bis zur rechten Slide-Kante,
|
||
damit der Pfeil dort wirklich austritt und nicht 92 px davor endet.
|
||
Die Zeichnung selbst behaelt ihre Koordinaten. */
|
||
const W = 988, H = 730;
|
||
const LX = 60, RX = 800, OY = 70, UY = 650, KNICK = 58;
|
||
|
||
const wuensche = [
|
||
{ t: "Workflow-Engine", g: glyZahnrad },
|
||
{ t: "Projektmanagement", g: glyBoard },
|
||
{ t: "Chat", g: glyBlase },
|
||
{ t: "Mitarbeiter-Management", g: glyLeute },
|
||
];
|
||
|
||
// Blatt mit abgeknickter Ecke unten rechts.
|
||
const blatt = strich(LX, OY, RX, OY, { ue: 4 }) +
|
||
strich(RX, OY, RX, UY - KNICK, { ue: 3 }) +
|
||
strich(RX, UY - KNICK, RX - KNICK, UY, { ue: 2 }) +
|
||
strich(RX - KNICK, UY, LX, UY, { ue: 4 }) +
|
||
strich(LX, UY, LX, OY, { ue: 4 });
|
||
const knick = glatt([[RX, UY - KNICK], [RX - KNICK + 8, UY - KNICK + 12],
|
||
[RX - KNICK, UY]], false);
|
||
|
||
let raus = zug(blatt, { w: 3.4 }) + zug(knick, { w: 2.6, op: .75 });
|
||
|
||
wuensche.forEach((w, i) => {
|
||
const y = 180 + i * 120, bx = LX + 60, by = y - 30;
|
||
raus += zug(strich(LX + 60, y + 26, RX - 60, y + 26, { j: 1, b: 1 }), { w: 2, op: .22 });
|
||
raus += zug(rechteck(bx, by, 38, 38, { ue: 3 }), { f: GELB, w: 3.4 });
|
||
// Der Haken laeuft ueber den Kasten hinaus, wie von Hand abgehakt.
|
||
raus += zug(glatt([[bx + 7, by + 21], [bx + 15, by + 30], [bx + 33, by + 5]], false),
|
||
{ f: GELB, w: 4.8 });
|
||
raus += '<text x="' + (LX + 126) + '" y="' + y + '" font-family="Manrope, sans-serif" ' +
|
||
'font-size="32" font-weight="600" letter-spacing="-0.015em" fill="' + TINTE + '">' +
|
||
w.t + '</text>';
|
||
/* Die Zeichen stehen in einer Spalte statt direkt hinter dem Wort. Die
|
||
Zeilen sind unterschiedlich lang, direkt angehaengt haetten sie eine
|
||
ausgefranste Kante ergeben. */
|
||
raus += zug(w.g(RX - 108, y - 11), { w: 2.5 });
|
||
});
|
||
|
||
/* Der Pfeil zur naechsten Slide: kurz, gerade, dick, auf halber
|
||
Slide-Hoehe. Die Slide ist 1350 hoch, die Zeichenflaeche beginnt bei
|
||
500, die Mitte liegt hier also auf 175.
|
||
|
||
Hier steht nur der hintere Teil des Schafts, die Spitze sitzt drueben
|
||
auf Slide 5. Beim Swipen zeigt Instagram zwischen den Slides ein paar
|
||
Pixel Abstand, der Pfeil ist dort also zerschnitten. Genau das traegt
|
||
den Effekt: eine halbierte Form zieht das Auge weiter (rules.md,
|
||
Paragraf 5).
|
||
|
||
Er steht ausserhalb der Blattdrehung, sonst laege sein Austritt nicht
|
||
mehr auf derselben Hoehe wie der Eintritt drueben.
|
||
|
||
In Tinte, nicht in Gelb: Gelb bedeutet in diesem Beitrag Verbindung
|
||
und gehoert der Zeichnung. Der Pfeil gehoert nicht dazu, er weist nur
|
||
weiter. */
|
||
const nHalb = () => 14;
|
||
const naht =
|
||
zug(kritzel(818, W - 2, 175, nHalb, 11), { w: 4.4, op: .92 }) +
|
||
zug(linienzug([[W, 157], [900, 154], [814, 156], [814, 194],
|
||
[900, 196], [W, 193]]), { w: 3.6 });
|
||
|
||
/* Leicht schief aufs Blatt gelegt. Genau senkrecht wirkt gesetzt, und ein
|
||
Zettel liegt nie gerade. */
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' +
|
||
'<g transform="rotate(-1.3 448 360)">' + raus + '</g>' + naht + '</svg>';
|
||
}
|
||
|
||
/* ─── Slide 6: was nach der Anmeldung passiert ───────────────────────────
|
||
Uebernahme der Sektion "So geht\'s weiter" von der Website, in derselben
|
||
Handschrift wie Slide 5. Drei Schritte an einer Leitung, Wabe statt Kreis,
|
||
der dritte Schritt blass: er hat noch nicht stattgefunden. */
|
||
function visWarteliste() {
|
||
keim(20260814);
|
||
const W = 896, H = 730, HX = 66, R = 54, TX = 176, HOEH = R * 0.866;
|
||
|
||
const schritte = [
|
||
{ y: 112, titel: "Du trägst dich ein", ikon: ikonBrief,
|
||
zeilen: ["Eine E-Mail genügt. Kein Vertrag,", "keine Kreditkarte, unverbindlich."] },
|
||
{ y: 352, titel: "Wir melden uns", ikon: ikonBlaseKlein,
|
||
zeilen: ["Sobald HiveGrid für deinen Betrieb", "bereit ist, persönlich und ohne Spam."] },
|
||
{ y: 592, titel: "Du bist von Anfang an dabei", ikon: ikonKonfetti,
|
||
zeilen: ["Unter den Ersten, die HiveGrid", "nutzen und mitgestalten."] },
|
||
];
|
||
|
||
const txt = (x, y, groesse, gewicht, farbe, sperr, inhalt) =>
|
||
'<text x="' + x + '" y="' + y + '" font-family="Manrope, sans-serif" font-size="' +
|
||
groesse + '" font-weight="' + gewicht + '" letter-spacing="' + sperr +
|
||
'" fill="' + farbe + '">' + inhalt + '</text>';
|
||
|
||
let raus = "";
|
||
|
||
// Von Wabe zu Wabe ein Pfeil. Er endet kurz vor der naechsten Wabe, sonst
|
||
// laeuft die Spitze in die Kante und ist nicht mehr als Spitze zu lesen.
|
||
let pfeile = "";
|
||
for (let i = 0; i < schritte.length - 1; i++) {
|
||
pfeile += pfeil(HX, schritte[i].y + HOEH + 8, HX, schritte[i + 1].y - HOEH - 9);
|
||
}
|
||
raus += zug(pfeile, { f: GELB, w: 3.8 });
|
||
|
||
schritte.forEach(sc => {
|
||
raus += zug(wabeSchraff(HX, sc.y, R, 11), { f: GELB, w: 2, op: .5 });
|
||
raus += zug(wabe(HX, sc.y, R), { f: GELB, w: 4 });
|
||
raus += sc.ikon(HX, sc.y, TINTE);
|
||
raus += txt(TX, sc.y - 26, 40, 800, TINTE, "-0.02em", sc.titel);
|
||
sc.zeilen.forEach((z, k) => {
|
||
raus += txt(TX, sc.y + 24 + k * 34, 26, 500, "#555555", "0", z);
|
||
});
|
||
});
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + raus + '</svg>';
|
||
}
|
||
|
||
function visVerbinden() {
|
||
keim(20260812);
|
||
/* Die Flaeche beginnt an der linken Slide-Kante statt bei 92, damit der
|
||
Pfeil von Slide 4 dort ankommt. Die Zeichnung rueckt um dieselben 92 px
|
||
nach rechts, steht also unveraendert auf der Slide. */
|
||
const W = 988, H = 730, cx = 540, cy = 350, R = 160;
|
||
const RX = 352, RY = 300;
|
||
|
||
// Die Winkel sind nicht frei gewaehlt: sie sind die sechs Eckwinkel der
|
||
// Wabe. Dadurch sitzt jeder Knoten auf einer Ecke.
|
||
const sats = [
|
||
{ a: 240, name: "KI", ikon: ikonKi },
|
||
{ a: 300, name: "Chat", ikon: ikonChat },
|
||
{ a: 0, name: "Mitarbeiter", ikon: ikonCrm },
|
||
{ a: 60, name: "Automation", ikon: ikonAuto },
|
||
{ a: 120, name: "Projekte", ikon: ikonBoard },
|
||
{ a: 180, name: "Formulare", ikon: ikonFormular },
|
||
];
|
||
|
||
let speichen = "", knoten = "", bilder = "", schrift = "";
|
||
|
||
sats.forEach(s => {
|
||
const r = s.a * Math.PI / 180;
|
||
const sx = cx + Math.cos(r) * RX, sy = cy + Math.sin(r) * RY;
|
||
const ix = cx + Math.cos(r) * R, iy = cy + Math.sin(r) * R; // Ecke der Wabe
|
||
const vx = sx - ix, vy = sy - iy, vl = Math.hypot(vx, vy) || 1;
|
||
const ux = vx / vl, uy = vy / vl;
|
||
const ax = sx - ux * (s.ikon.rad + 20), ay = sy - uy * (s.ikon.rad + 20);
|
||
speichen += strich(ix + ux * 12, iy + uy * 12, ax - ux * 11, ay - uy * 11, { j: 1.2 });
|
||
knoten += kringel(ix, iy, 10.5, 10.5, { ueber: .1 }) + kringel(ax, ay, 10.5, 10.5, { ueber: .1 });
|
||
bilder += s.ikon(sx, sy);
|
||
schrift += '<text x="' + n1(sx) + '" y="' + n1(sy + s.ikon.unten) + '" text-anchor="middle" ' +
|
||
'font-family="Manrope, sans-serif" font-size="28" font-weight="700" ' +
|
||
'letter-spacing="-0.01em" fill="' + TINTE + '">' + s.name + '</text>';
|
||
});
|
||
|
||
// Die Wabe wird zweimal gezogen. Ein zweiter Umriss auf groesserem Radius
|
||
// sah nach Versehen aus, derselbe Radius nach zweitem Zug.
|
||
// Breite und Hoehe stehen als Attribute mit dran, nicht nur im viewBox.
|
||
// Ohne sie hat Chrome die Hoehe nicht aus dem Seitenverhaeltnis abgeleitet,
|
||
// das SVG wurde zu hoch und die Zeichnung lief in den Fliesstext.
|
||
/* Die Fortsetzung des Pfeils von Slide 4, auf derselben Hoehe: der vordere
|
||
Teil des Schafts und die Spitze. */
|
||
/* Die Fuellung folgt der Form: im Schaft konstant, in der Spitze laeuft
|
||
der Ausschlag auf null zu. */
|
||
const zHalb = x => x <= 104 ? 14 : Math.max(2, 40 * (1 - (x - 104) / 68));
|
||
const zulauf =
|
||
zug(kritzel(2, 168, 175, zHalb, 11), { w: 4.4, op: .92 }) +
|
||
zug(linienzug([[0, 157], [54, 155], [104, 156], [104, 130], [172, 175],
|
||
[104, 220], [104, 194], [54, 196], [0, 193]]), { w: 3.6 });
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' +
|
||
zulauf +
|
||
bilder + schrift +
|
||
zug(speichen, { f: GELB, w: 4.4 }) +
|
||
zug(wabe(cx, cy, R), { f: GELB, w: 4.8 }) +
|
||
zug(wabe(cx, cy, R - 2), { f: GELB, w: 3.2, op: .72 }) +
|
||
zug(knoten, { f: GELB, w: 3.8 }) +
|
||
nabe(cx, cy) +
|
||
'</svg>';
|
||
}
|
||
|
||
/* ═══ Animationen ════════════════════════════════════════════════════════
|
||
Sie stehen mit Absicht nicht in SLIDES. Eine Animation ist kein Standbild
|
||
im Streifen: sie wird einzeln gebaut, einzeln exportiert und geht als
|
||
eigenes Element ins Carousel -- oder eben nicht mit, wenn nur die Bilder
|
||
gepostet werden. Der Bildexport laesst sie deshalb unberuehrt.
|
||
|
||
Aufbau sonst gleich wie eine Slide: dieselben Felder, dieselbe
|
||
Zeichenfunktion aus VIS, dasselbe Blatt in 1080 x 1350. Der Unterschied
|
||
ist `dauer` und der zweite Wert t, den die Zeichenfunktion bekommt. */
|
||
const ANIMS = [];
|
||
|
||
/* ═══ Zeitachse ══════════════════════════════════════════════════════════
|
||
Eine Slide mit `dauer` ist animiert. Ihre Zeichenfunktion bekommt dann
|
||
einen zweiten Wert: den Fortschritt t zwischen 0 und 1. Sonst aendert
|
||
sich nichts -- dieselbe Ueberschrift, derselbe Punktgrund, dieselbe
|
||
Schutzzone. Ein animiertes Bild ist hier eine Slide, die sich bewegt,
|
||
und kein zweites Format.
|
||
|
||
ANIM_T haelt je Slide den aktuellen Stand. Das Bauskript setzt ihn ueber
|
||
window.animSetzen() und schiesst dann ein Bild ab; die Vorschau in der
|
||
Seite laesst ihn ueber requestAnimationFrame laufen. Beide gehen durch
|
||
dieselbe Tuer, damit die Vorschau zeigt, was hinterher herauskommt. */
|
||
const ANIM_T = {};
|
||
const animT = j => ANIM_T[j] || 0;
|
||
|
||
/* Ein paar Verlaeufe. Linear sieht nach Maschine aus, deshalb sind Ein-
|
||
und Ausgang gedaempft. `stufe` blendet einen Abschnitt zwischen zwei
|
||
Zeitpunkten ein und liefert 0 davor und 1 danach. */
|
||
const daempfen = x => x <= 0 ? 0 : x >= 1 ? 1 : x < .5
|
||
? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||
const rueck = x => x <= 0 ? 0 : x >= 1 ? 1
|
||
: 1 + 2.7 * Math.pow(x - 1, 3) + 1.7 * Math.pow(x - 1, 2);
|
||
const stufe = (t, von, bis) => t <= von ? 0 : t >= bis ? 1 : (t - von) / (bis - von);
|
||
|
||
const VIS = {
|
||
karte: j =>
|
||
'<div class="karte-halter">' + visKarte(layoutState[j]) +
|
||
'<div class="karte">' +
|
||
'<div class="karte-grund"><img alt="" src="' + assets["./assets/team/noah-bg.webp"] + '">' +
|
||
'<span class="karte-wash"></span></div>' +
|
||
'<img class="karte-person" alt="Noah Geerkens" src="' +
|
||
assets["./assets/team/noah-person.webp"] + '">' +
|
||
'<div class="karte-name"><span>Noah</span><small>Gründer</small></div>' +
|
||
'</div></div>',
|
||
|
||
schiene: () => visProzesse(),
|
||
|
||
ersetzen: () => visWechseln(),
|
||
|
||
lose: () => visMehr(),
|
||
|
||
verbinden: () => visVerbinden(),
|
||
|
||
warteliste: () => visWarteliste(),
|
||
};
|
||
|
||
/* ─── Das Deck ────────────────────────────────────────────────────────────
|
||
Welche Slides der Post hat, steht nicht mehr fest. Jeder Eintrag ist
|
||
entweder eine mitgelieferte Vorlage (ref) oder eine selbst angelegte, und
|
||
eigene Texte liegen als Überschreibung daneben. So bleibt eine Änderung an
|
||
einer Vorlage im Code für alle wirksam, die sie nicht überschrieben haben
|
||
— hätte man die Slides vollständig in den Stand geschrieben, wäre der dort
|
||
eingefroren. */
|
||
let slideListe = VORLAGEN.map(v => ({ ref: v.key }));
|
||
|
||
const SLIDES = [];
|
||
|
||
function slideAus(eintrag, i) {
|
||
const basis = eintrag.ref ? VORLAGEN.find(v => v.key === eintrag.ref) : null;
|
||
const s = kopie(basis || NEUE_SLIDE);
|
||
Object.assign(s, eintrag.werte || {});
|
||
// null heisst "weg", nicht "leeres Objekt": nur so laesst sich ein zweites
|
||
// Bild, das die Vorlage mitbringt, wieder abwaehlen.
|
||
if (s.media2 === null) delete s.media2;
|
||
// Der Schlüssel landet im Dateinamen und muss eindeutig bleiben.
|
||
s.key = (basis ? basis.key : (eintrag.key || "eigen")) + (eintrag.ref ? "" : "-" + (i + 1));
|
||
return s;
|
||
}
|
||
|
||
function slidesNeuAufbauen() {
|
||
SLIDES.length = 0;
|
||
slideListe.forEach((e, i) => SLIDES.push(slideAus(e, i)));
|
||
}
|
||
|
||
/* Im Eingabefeld tippt man Text, nicht Auszeichnung: *Wort* wird zum Akzent,
|
||
ein Zeilenumbruch zum Umbruch. Gespeichert wird trotzdem das fertige
|
||
Markup, weil die Vorlagen es schon so mitbringen. */
|
||
function textZuHtml(t) {
|
||
return String(t)
|
||
.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c]))
|
||
.replace(/\*([^*\n]+)\*/g, '<span class="a">$1</span>')
|
||
.replace(/\r?\n/g, "<br>");
|
||
}
|
||
function htmlZuText(h) {
|
||
return String(h == null ? "" : h)
|
||
.replace(/<br\s*\/?>/gi, "\n")
|
||
.replace(/<span class="a">([\s\S]*?)<\/span>/gi, "*$1*")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||
}
|
||
|
||
/* Standardwerte. Reset und die Übersicht auf der Seite lesen beide von hier,
|
||
damit die Doku nicht auseinanderläuft. size = null heisst: nicht über die
|
||
Schriftgröße steuerbar (Bilder, Geräte). */
|
||
const STANDARD = {
|
||
"badge": { label: "Tag", size: 26, prop: "font-size" },
|
||
"headline": { label: "Überschrift", size: 74, prop: "font-size" },
|
||
"body": { label: "Beschreibung", size: 30, prop: "font-size" },
|
||
"wabe-tagline": { label: "Tagline", size: 74, prop: "font-size" },
|
||
"wabe-claim": { label: "Claim", size: 31, prop: "font-size" },
|
||
"title-head": { label: "Überschrift", size: 88, prop: "font-size" },
|
||
"title-sub": { label: "Claim", size: 31, prop: "font-size" },
|
||
"wabe-logo": { label: "Logo", size: 430, prop: "width" },
|
||
"logo-full": { label: "Logo", size: 560, prop: "width" },
|
||
"screen-logo": { label: "Logo im Screen", size: 430, prop: "width" },
|
||
"laptop": { label: "Laptop", size: 960, prop: "width" },
|
||
"szene": { label: "Laptop · Board", size: 880, prop: "width" },
|
||
"vis": { label: "Visualisierung", size: null, prop: "width" },
|
||
"mock": { label: "Bild", size: null, prop: "width" },
|
||
"mock-2": { label: "Zweites Bild", size: null, prop: "width" },
|
||
"note": { label: "Notizzettel", size: null, prop: "width" },
|
||
"hex-frame": { label: "Wabe", size: 778, prop: "width" },
|
||
};
|
||
const EDIT_KEYS = Object.keys(STANDARD);
|
||
|
||
/* Der Standard des Bildrahmens hängt an der Slide: Der Form Builder hat ein
|
||
Hochformat von 540, alle anderen 1090. Deshalb nicht aus der Tabelle,
|
||
sondern aus der Slide selbst. */
|
||
function stdSize(key, i) {
|
||
if (key === "mock") return SLIDES[i].mockW || 1090;
|
||
if (key === "mock-2") return (SLIDES[i].media2 || {}).w || 400;
|
||
if (key === "note") return (SLIDES[i].note || {}).w || 320;
|
||
if (key === "vis") return SLIDES[i].visW || 900;
|
||
return (STANDARD[key] || {}).size;
|
||
}
|
||
|
||
// Schriftgrößen in 2er-Schritten, Breiten in 2 % ihrer Ausgangsgröße:
|
||
// 2 px auf ein 1090 px breites Bild würde man nie sehen.
|
||
function stepFor(key, i) {
|
||
const std = STANDARD[key];
|
||
if (!std || !std.prop) return 0;
|
||
if (std.prop === "font-size") return 2;
|
||
return Math.max(4, Math.round(stdSize(key, i) * 0.02));
|
||
}
|
||
|
||
slidesNeuAufbauen();
|
||
|
||
let edits = SLIDES.map(() => ({}));
|
||
|
||
let picked = null; // { slide, key }
|
||
// Bearbeiten ist immer an. "aus" heisst Vorschau: alles festgelegt.
|
||
let editMode = "an";
|
||
|
||
let current = 0;
|
||
let variant = "dunkel", tilt = "an", superSample = 2, view = "streifen", tagOut = "an", frameStyle = "chamfer";
|
||
|
||
/* ─── Projektstand ────────────────────────────────────────────────────────
|
||
Alles, was am Projekt hängt, wird gespeichert: Einstellungen, Layouts,
|
||
Muster und die Verschiebungen im Canvas. Das Werkzeug soll den Stand
|
||
halten, nicht der Kopf. */
|
||
const PROJECT = "post2";
|
||
const STORE = "hivegrid.projekt." + PROJECT;
|
||
const STATE_URL = "./state/" + PROJECT + ".json";
|
||
|
||
const STATE_V = 6;
|
||
|
||
function currentState() {
|
||
return {
|
||
v: STATE_V, variant, tilt, superSample, view, tagOut, frameStyle,
|
||
current, layoutState, bgState, edits,
|
||
// Welche Slides der Post ueberhaupt hat. Ohne das waere eine selbst
|
||
// angelegte Slide nach dem Neuladen weg.
|
||
deck: slideListe,
|
||
};
|
||
}
|
||
|
||
/* ─── Angleichung des Tags (einmalig, Stand 1 → 2) ───────────────────────
|
||
Der Tag war auf jeder Slide einzeln gezogen worden und stand deshalb
|
||
überall etwas anders. Er soll auf allen Feature-Slides denselben Abstand
|
||
zur oberen und linken Kante haben; Maß ist die erste Feature-Slide.
|
||
Das läuft hier und nicht über die Datei: solange ein Tab offen ist, hält
|
||
der seinen Stand im Speicher und schreibt jede Änderung an der Datei beim
|
||
nächsten Klick wieder zu. Im Ladepfad kann er das nicht. */
|
||
function migrateState(d) {
|
||
if (!d || (d.v || 1) >= STATE_V) return false;
|
||
const alt = d.v || 1;
|
||
if (alt < 2) {
|
||
const feat = SLIDES.map((s, i) => (s.type === "feature" ? i : -1)).filter(i => i >= 0);
|
||
const ref = feat.length && Array.isArray(d.edits) ? (d.edits[feat[0]] || {}).badge : null;
|
||
if (ref) {
|
||
for (const i of feat.slice(1)) {
|
||
const e = d.edits[i] || (d.edits[i] = {});
|
||
e.badge = Object.assign({}, e.badge, { dx: ref.dx || 0, dy: ref.dy || 0 });
|
||
}
|
||
}
|
||
}
|
||
// Stand 3: Das Titel-Mockup ist ein anderes Bild mit anderem Format
|
||
// geworden. Breite und Versatz von vorher passen dazu nicht mehr und
|
||
// wuerden das Geraet ueber die Slide hinaus schieben.
|
||
if (alt < 3 && Array.isArray(d.edits)) {
|
||
for (const e of d.edits) { if (e) delete e.szene; }
|
||
}
|
||
// Stand 4: Automation & KI steht jetzt vor Vertrieb. Der Stand haengt an der
|
||
// Position, nicht am Slide-Schluessel — ohne diesen Tausch laegen die
|
||
// gezogenen Positionen, das Muster und das Layout an der falschen Slide.
|
||
if (alt < 4) {
|
||
const tausch = arr => {
|
||
if (Array.isArray(arr) && arr.length > 2) { const t = arr[1]; arr[1] = arr[2]; arr[2] = t; }
|
||
};
|
||
tausch(d.edits); tausch(d.bgState); tausch(d.layoutState);
|
||
if (d.current === 1) d.current = 2;
|
||
else if (d.current === 2) d.current = 1;
|
||
}
|
||
// Stand 5: Das Tag ist ausgeblendet, seine gespeicherte Stelle also ohne
|
||
// Wirkung. Der Eintrag wird geraeumt, damit keine Werte stehenbleiben,
|
||
// die niemand mehr nachvollziehen kann.
|
||
if (alt < 5 && Array.isArray(d.edits)) {
|
||
for (const e of d.edits) { if (e) delete e.badge; }
|
||
}
|
||
d.v = STATE_V;
|
||
return true;
|
||
}
|
||
|
||
/* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die
|
||
Rückmeldung nicht auf das Netz wartet, und gebündelt in
|
||
state/<projekt>.json. Nur die Datei lässt sich pushen, also ist sie die
|
||
Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */
|
||
let pushTimer = null;
|
||
|
||
/* Ein vorhandener Stand, der sich nicht lesen laesst, ist ein Grund zum
|
||
Anhalten und nicht zum Weiterarbeiten: sonst laeuft das Blatt mit der
|
||
Voreinstellung an und schreibt die beim ersten Klick ueber genau die
|
||
Datei, die gerade nicht verstanden wurde. Solange die Sperre steht, geht
|
||
nichts mehr raus, weder in die Datei noch in den localStorage. */
|
||
/* Im Uebersichts-Modus wird nichts gespeichert: drei Kacheln, die beim
|
||
Schliessen ihren Stand zurueckschreiben, waeren genau die Falle aus
|
||
POST-2.md 6. Die Konstante steht hier oben, weil stateLocked sie
|
||
braucht -- weiter unten waere sie noch nicht initialisiert. */
|
||
const NUR_STREIFEN = document.documentElement.dataset.nur === "streifen";
|
||
let stateLocked = NUR_STREIFEN;
|
||
function lockState(grund) {
|
||
stateLocked = true;
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
setStatus("Stand nicht geladen (" + grund + "). Es wird nichts gespeichert, "
|
||
+ "damit die vorhandene Datei nicht ueberschrieben wird.", "err");
|
||
}
|
||
|
||
/* ─── Rueckgaengig ───────────────────────────────────────────────────────
|
||
Haengt an saveState statt an den 25 Stellen, die etwas aendern: was
|
||
gespeichert wird, ist genau das, was rueckgaengig gemacht werden soll.
|
||
Ziehen speichert erst beim Loslassen, eine Bewegung ist also bereits ein
|
||
Schritt und nicht hundert. */
|
||
const UNDO_MAX = 100;
|
||
let undoStack = [], redoStack = [], lastSnap = null, restoring = false;
|
||
|
||
// Tiefe Kopie: currentState() gibt die laufenden Arrays heraus, und beim
|
||
// Ziehen wird in edits[i][key] direkt hineingeschrieben. Ohne Kopie wuerde
|
||
// sich ein abgelegter Stand nachtraeglich mitveraendern.
|
||
|
||
// Vergleich mit sortierten Schluesseln, damit die Reihenfolge, in der dx und
|
||
// dy angelegt wurden, keinen Unterschied vortaeuscht.
|
||
function stabil(x) {
|
||
if (Array.isArray(x)) return x.map(stabil);
|
||
if (x && typeof x === "object") {
|
||
const o = {};
|
||
for (const k of Object.keys(x).sort()) o[k] = stabil(x[k]);
|
||
return o;
|
||
}
|
||
return x;
|
||
}
|
||
|
||
// Ein Slide-Wechsel allein ist nichts, was man rueckgaengig machen will.
|
||
// show() speichert bei jedem Wechsel; ohne diese Ausnahme bestuende der
|
||
// Stapel zur Haelfte aus Auswahlwechseln.
|
||
function nurAuswahlAnders(a, b) {
|
||
const ohne = s => { const c = Object.assign({}, s); delete c.current; return JSON.stringify(stabil(c)); };
|
||
return ohne(a) === ohne(b);
|
||
}
|
||
|
||
function merkeFuerUndo(state) {
|
||
const snap = kopie(state);
|
||
if (lastSnap && !nurAuswahlAnders(lastSnap, snap)) {
|
||
undoStack.push(lastSnap);
|
||
if (undoStack.length > UNDO_MAX) undoStack.shift();
|
||
redoStack.length = 0; // neuer Zweig: das Wiederherstellen verfaellt
|
||
}
|
||
lastSnap = snap;
|
||
}
|
||
|
||
/* Zurueckspielen laeuft ueber takeState, also denselben Weg wie das Laden,
|
||
und zieht danach die gesamte Oberflaeche nach. Von Hand jeden Schalter
|
||
einzeln zu setzen waere die Stelle, an der man einen vergisst. */
|
||
function restoreState(d) {
|
||
restoring = true;
|
||
try {
|
||
takeState(kopie(d));
|
||
deck.dataset.view = view;
|
||
press("variants", "v", variant);
|
||
press("tilts", "t", tilt);
|
||
press("quality", "q", String(superSample));
|
||
press("tagouts", "o", tagOut);
|
||
press("frames", "f", frameStyle);
|
||
press("views", "w", view);
|
||
// Nach einem Schritt, der Slides hinzugefuegt, geloescht oder umsortiert
|
||
// hat, stimmt der Aufbau im Canvas nicht mehr mit dem Stand ueberein.
|
||
if (deck.querySelectorAll(".holder").length !== SLIDES.length) {
|
||
picked = null;
|
||
deckNeuBauen();
|
||
} else {
|
||
SLIDES.forEach((s, i) => rebuildPage(i));
|
||
applyAll();
|
||
applyEditsAll();
|
||
}
|
||
anzahlNachziehen();
|
||
show(current);
|
||
if (picked) pick(picked.slide, picked.key); else syncEditPanel();
|
||
} finally { restoring = false; }
|
||
// Der zurueckgespielte Stand ist ab jetzt der Bezugspunkt, sonst legt der
|
||
// naechste Speichervorgang ihn gleich wieder als Schritt ab.
|
||
lastSnap = kopie(currentState());
|
||
saveState();
|
||
}
|
||
|
||
function undo() {
|
||
if (stateLocked) return;
|
||
if (!undoStack.length) { setStatus("Nichts mehr rueckgaengig zu machen", "err"); return; }
|
||
redoStack.push(kopie(currentState()));
|
||
restoreState(undoStack.pop());
|
||
setStatus("Rueckgaengig · noch " + undoStack.length + " Schritte", "ok");
|
||
}
|
||
|
||
function redo() {
|
||
if (stateLocked) return;
|
||
if (!redoStack.length) { setStatus("Nichts wiederherzustellen", "err"); return; }
|
||
undoStack.push(kopie(currentState()));
|
||
restoreState(redoStack.pop());
|
||
setStatus("Wiederhergestellt · noch " + redoStack.length + " Schritte", "ok");
|
||
}
|
||
|
||
function saveState() {
|
||
if (stateLocked) return;
|
||
const state = currentState();
|
||
if (!restoring) merkeFuerUndo(state);
|
||
try { localStorage.setItem(STORE, JSON.stringify(state)); }
|
||
catch (err) { console.warn("Speichern fehlgeschlagen", err); }
|
||
markSaved();
|
||
clearTimeout(pushTimer);
|
||
pushTimer = setTimeout(() => pushState(state), 600);
|
||
}
|
||
|
||
async function pushState(state) {
|
||
pushTimer = null;
|
||
if (stateLocked) return;
|
||
try {
|
||
const res = await fetch("/state?project=" + PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(state),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
} catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); }
|
||
}
|
||
|
||
// Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt.
|
||
addEventListener("beforeunload", () => {
|
||
if (!pushTimer || stateLocked) return;
|
||
clearTimeout(pushTimer);
|
||
navigator.sendBeacon("/state?project=" + PROJECT,
|
||
new Blob([JSON.stringify(currentState())], { type: "application/json" }));
|
||
});
|
||
|
||
async function loadState() {
|
||
let d = null, fromFile = false, fileSeen = false;
|
||
// Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch
|
||
// gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten.
|
||
try {
|
||
const res = await fetch(STATE_URL, { cache: "no-store" });
|
||
if (res.ok) { fileSeen = true; d = await res.json(); fromFile = true; }
|
||
} catch (_) {}
|
||
// Datei ist da, laesst sich aber nicht lesen: anhalten statt ueberschreiben.
|
||
if (fileSeen && !d) { lockState("Datei unlesbar"); return false; }
|
||
if (!d) {
|
||
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
|
||
}
|
||
if (!d) return false;
|
||
// Jeder Stand von 1 bis zum aktuellen ist gueltig; aeltere hebt migrateState
|
||
// an. Frueher standen hier nur 1 und 2 fest verdrahtet, dadurch wurde jeder
|
||
// frisch gespeicherte Stand beim Laden verworfen und von der Voreinstellung
|
||
// ueberschrieben.
|
||
if (!(d.v >= 1 && d.v <= STATE_V)) {
|
||
if (fromFile) { lockState("Stand " + d.v + ", dieses Blatt kennt bis " + STATE_V); }
|
||
return false;
|
||
}
|
||
const migrated = migrateState(d);
|
||
// Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben,
|
||
// sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen.
|
||
// Nach einer Angleichung ebenso, damit sie nicht bei jedem Laden erneut greift.
|
||
if (!fromFile || migrated) setTimeout(() => pushState(currentState()), 0);
|
||
takeState(d);
|
||
return true;
|
||
}
|
||
|
||
/* Einen Stand in die laufenden Variablen uebernehmen. Steht bewusst als
|
||
eigene Funktion da: der Ladepfad und das Rueckgaengigmachen gehen beide
|
||
hier durch, sonst zieht der eine ein Feld nach, das der andere vergisst. */
|
||
function takeState(d) {
|
||
/* Zuerst das Deck: Anzahl und Art der Slides stehen darin, und alle
|
||
folgenden Felder haengen an dieser Anzahl. Ein Stand ohne Deck stammt
|
||
aus der Zeit fester fuenf Slides und behaelt die Vorlagen. */
|
||
if (Array.isArray(d.deck) && d.deck.length) {
|
||
slideListe = d.deck.map(e => ({
|
||
ref: e && e.ref, key: e && e.key, werte: (e && e.werte) || {},
|
||
}));
|
||
slidesNeuAufbauen();
|
||
passeFelderAn();
|
||
}
|
||
// Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am
|
||
// Aufbau dürfen keinen kaputten Stand einspielen.
|
||
if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) {
|
||
d.layoutState.forEach((id, i) => {
|
||
if (SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = id;
|
||
});
|
||
}
|
||
if (Array.isArray(d.bgState) && d.bgState.length === SLIDES.length) {
|
||
d.bgState.forEach((b, i) => { if (b && b.bg) bgState[i] = b; });
|
||
}
|
||
if (Array.isArray(d.edits) && d.edits.length === SLIDES.length) edits = d.edits;
|
||
variant = d.variant || variant;
|
||
tilt = d.tilt || tilt;
|
||
superSample = d.superSample || superSample;
|
||
/* Der Uebersichts-Modus zeigt immer den Streifen. Steht im Stand
|
||
"einzeln", waere sonst nur die zuletzt gewaehlte Slide zu sehen --
|
||
und stripWidth() meldete 1080 statt der vollen Breite. */
|
||
view = NUR_STREIFEN ? "streifen" : (d.view || view);
|
||
tagOut = d.tagOut || tagOut;
|
||
frameStyle = d.frameStyle || frameStyle;
|
||
current = Number.isInteger(d.current) ? Math.min(d.current, SLIDES.length - 1) : 0;
|
||
}
|
||
|
||
let savedTimer = null;
|
||
function markSaved() {
|
||
const el = document.getElementById("saved");
|
||
if (!el) return;
|
||
el.textContent = "gespeichert";
|
||
el.classList.add("on");
|
||
clearTimeout(savedTimer);
|
||
savedTimer = setTimeout(() => el.classList.remove("on"), 1200);
|
||
}
|
||
|
||
function forgetProject() {
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
try { localStorage.removeItem(STORE); } catch (_) {}
|
||
// Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten
|
||
// Laden wieder ein.
|
||
fetch("/state?project=" + PROJECT, { method: "DELETE" })
|
||
.catch(() => {})
|
||
.then(() => location.reload());
|
||
}
|
||
// Per-slide so a single slide can carry a different ground.
|
||
// Der Grund ist fest das Raster. Einstellbar bleibt nur seine Staerke.
|
||
const bgState = SLIDES.map(() => ({ bg: "raster", int: "dezent" }));
|
||
// Each slide picks its own layout from its own catalogue.
|
||
const layoutState = SLIDES.map(sl => sl.layouts[0].id);
|
||
// Every asset is inlined once at boot, so re-rendering a layout stays synchronous.
|
||
const assets = {};
|
||
let fontEmbedCss = "";
|
||
|
||
/* Der Streifen wird auf die verfügbare Breite skaliert, die Einzelansicht auf
|
||
feste 45 %. Der Rahmen bekommt die skalierte Größe, damit die Seite nicht
|
||
springt. */
|
||
/* zoom === null bedeutet: automatisch einpassen. Sobald der Nutzer zoomt,
|
||
hält der Wert, bis er auf "Fit" zurückgeht. */
|
||
// Im Uebersichts-Modus steht der Streifen fest auf 100 % -- skaliert wird aussen.
|
||
let zoom = NUR_STREIFEN ? 1 : null;
|
||
|
||
function stripWidth() { return view === "streifen" ? 1080 * SLIDES.length : 1080; }
|
||
|
||
function autoFit() {
|
||
const scroller = document.getElementById("scroller");
|
||
const availW = Math.max(320, scroller.clientWidth - 120);
|
||
const availH = Math.max(240, scroller.clientHeight - 120);
|
||
return Math.min(availW / stripWidth(), availH / 1350, 0.45);
|
||
}
|
||
|
||
/* Die Uebersicht muss wissen, wie breit dieser Streifen ist -- die Zahl
|
||
der Slides ist je Projekt anders. Sie kommt per postMessage statt ueber
|
||
einen Griff ins iframe-Dokument: so bleibt die Uebersicht von den
|
||
Interna der Post-Seite unabhaengig. */
|
||
function groesseMelden() {
|
||
if (!NUR_STREIFEN || parent === window) return;
|
||
parent.postMessage({ hg: "streifen", w: stripWidth(), h: 1350,
|
||
titel: document.title }, "*");
|
||
}
|
||
|
||
function fitStrip() {
|
||
const wrap = document.getElementById("strip-wrap");
|
||
const k = zoom || autoFit();
|
||
deck.style.transform = "scale(" + k + ")";
|
||
wrap.style.width = Math.round(stripWidth() * k) + "px";
|
||
wrap.style.height = Math.round(1350 * k) + "px";
|
||
const lvl = document.getElementById("zoom-fit");
|
||
if (lvl) lvl.textContent = zoom ? Math.round(k * 100) + " %" : "Fit";
|
||
groesseMelden();
|
||
}
|
||
|
||
function setZoom(k, anchor) {
|
||
const scroller = document.getElementById("scroller");
|
||
const before = zoom || autoFit();
|
||
const ax = anchor ? anchor.x : scroller.clientWidth / 2;
|
||
const ay = anchor ? anchor.y : scroller.clientHeight / 2;
|
||
/* Um den Mauszeiger zoomen, nicht um die linke obere Ecke — und dafür den
|
||
Punkt unter dem Zeiger messen statt ihn aus scrollLeft hochzurechnen.
|
||
Das Blatt sitzt in einer gepolsterten Fläche und wird zentriert, solange
|
||
Platz übrig ist; beides verschiebt den Ursprung. Ein reiner Dreisatz
|
||
ließ den Inhalt deshalb bei jedem Schritt um das Polster wegwandern. */
|
||
const s0 = scroller.getBoundingClientRect();
|
||
const r0 = deck.getBoundingClientRect();
|
||
const u = (s0.left + ax - r0.left) / before;
|
||
const v = (s0.top + ay - r0.top) / before;
|
||
zoom = Math.min(2, Math.max(0.05, k));
|
||
fitStrip();
|
||
const r1 = deck.getBoundingClientRect();
|
||
scroller.scrollLeft += (r1.left + u * zoom) - (s0.left + ax);
|
||
scroller.scrollTop += (r1.top + v * zoom) - (s0.top + ay);
|
||
}
|
||
|
||
const setStatus = (msg, kind) => {
|
||
statusEl.textContent = msg;
|
||
statusEl.className = "status" + (kind ? " " + kind : "");
|
||
};
|
||
|
||
// FeatherMoreVertical aus dem Kopf der StickyNote: drei Punkte, keine Linien.
|
||
const NOTE_MENU_SVG =
|
||
'<svg viewBox="0 0 24 24" fill="rgba(66,66,66,.72)">' +
|
||
'<circle cx="12" cy="5" r="1.9"/><circle cx="12" cy="12" r="1.9"/>' +
|
||
'<circle cx="12" cy="19" r="1.9"/></svg>';
|
||
|
||
|
||
// Positionen im 5400er Raum. Die Nähte liegen auf 1080 / 2160 / 3240 / 4320.
|
||
const BG_HTML =
|
||
'<div class="bg">' +
|
||
'<div class="layer-raster"></div>' +
|
||
'</div>';
|
||
|
||
function contentHtml(s, i) {
|
||
if (s.type === "feature") {
|
||
return '<p class="badge">' + s.eyebrow + '</p>' +
|
||
'<h2 class="headline">' + s.head + '</h2>' +
|
||
'<p class="body">' + s.body + '</p>';
|
||
}
|
||
const logo = variant === "dunkel" ? assets["./assets/brand/logo-white.svg"] : assets["./assets/brand/logo.svg"];
|
||
if (layoutState[i] === "szene") {
|
||
// Die Wortmarke steckt in diesem Mockup schon im Bild. Ein zweites Logo
|
||
// auf der Slide waere eine Dopplung, deshalb faellt hier die wabe-logo
|
||
// weg und die Zeile oben traegt nur die Tagline.
|
||
const dev = assets["./assets/mockups/macbook-fels.png"]
|
||
? '<div class="szene"><img class="szene-dev" alt="HiveGrid" src="' +
|
||
assets["./assets/mockups/macbook-fels.png"] + '"></div>'
|
||
: '<div class="laptop-missing">Mockup fehlt.<br>Das freigestellte PNG als <b>assets/mockups/macbook-fels.png</b> ablegen.</div>';
|
||
// Nur Ueberschrift und Mockup: der Beschreibungstext faellt hier weg.
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>für <span class="a">Handwerker</span></p>' +
|
||
'</div>' + dev;
|
||
}
|
||
if (layoutState[i] === "laptop-footer") {
|
||
const dev = assets["./assets/mockups/laptop.png"]
|
||
? '<div class="laptop">' +
|
||
'<img class="laptop-frame" alt="" src="' + assets["./assets/mockups/laptop.png"] + '">' +
|
||
'<div class="laptop-screen" data-blur="aus">' +
|
||
'<img class="laptop-shot" alt="" src="' + assets["./assets/screens/workflow.png"] + '">' +
|
||
'</div>' +
|
||
'</div>'
|
||
: '<div class="laptop-missing">Mockup fehlt.<br>Das transparente PNG als <b>assets/laptop.png</b> ablegen.</div>';
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
|
||
'<p class="wabe-claim">' + s.sub + '</p>' +
|
||
'</div>' + dev +
|
||
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">';
|
||
}
|
||
if (layoutState[i].indexOf("laptop") === 0) {
|
||
// In the logo variants the wordmark moves onto the display and the tagline
|
||
// takes over its slot on the hexagon line; "laptop-blur" additionally
|
||
// blurs the app behind it.
|
||
const onScreen = layoutState[i] !== "laptop";
|
||
const blurred = layoutState[i] === "laptop-blur";
|
||
const overlay = onScreen
|
||
? '<img class="screen-logo" alt="HiveGrid" src="' + assets["./assets/brand/logo.svg"] + '">'
|
||
: '';
|
||
const dev = assets["./assets/mockups/laptop.png"]
|
||
? '<div class="laptop">' +
|
||
'<img class="laptop-frame" alt="" src="' + assets["./assets/mockups/laptop.png"] + '">' +
|
||
'<div class="laptop-screen" data-blur="' + (blurred ? "an" : "aus") + '">' +
|
||
'<img class="laptop-shot" alt="" src="' + assets["./assets/screens/workflow.png"] + '">' + overlay +
|
||
'</div>' +
|
||
'</div>'
|
||
: '<div class="laptop-missing">Mockup fehlt.<br>Das transparente PNG als <b>assets/laptop.png</b> ablegen.</div>';
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
|
||
(onScreen ? '' : '<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">') +
|
||
'</div>' + dev +
|
||
'<p class="wabe-claim">' + s.sub + '</p>';
|
||
}
|
||
if (layoutState[i] === "wabe") {
|
||
return '<div class="title-top">' +
|
||
'<p class="wabe-tagline">Vom Handwerker<br>f\u00fcr <span class="a">Handwerker</span></p>' +
|
||
'<img class="wabe-logo" alt="HiveGrid" src="' + logo + '">' +
|
||
'</div>' +
|
||
'<div class="hex-frame"><div class="hex-shot">' +
|
||
'<img alt="" src="' + assets["./assets/screens/pipelines.png"] + '">' +
|
||
'</div></div>' +
|
||
'<p class="wabe-claim">' + s.sub + '</p>';
|
||
}
|
||
return '<img class="logo-full" alt="HiveGrid" src="' + logo + '">' +
|
||
'<h1 class="title-head">' + s.head + '</h1>' +
|
||
'<p class="title-sub">' + s.sub + '</p>';
|
||
}
|
||
|
||
// Eine Seite kann in mehreren Slides liegen; alle Kopien mitziehen.
|
||
function rebuildPage(j) {
|
||
deck.querySelectorAll('.page[data-j="' + j + '"]').forEach(pg => renderPage(pg, j));
|
||
}
|
||
|
||
function applyEditsAll() {
|
||
deck.querySelectorAll(".page").forEach(pg => applyEdits(pg, Number(pg.dataset.j)));
|
||
}
|
||
|
||
// Jedes bearbeitbare Element bekommt seinen Schlüssel, abgeleitet aus der Klasse.
|
||
function tagEditable(slideEl) {
|
||
for (const key of EDIT_KEYS) {
|
||
slideEl.querySelectorAll("." + key).forEach(n => { n.dataset.edit = key; });
|
||
}
|
||
// ".mock" trifft auch das zweite Bild, weil es dieselbe Klasse trägt.
|
||
// Der spezifischere Schlüssel muss gewinnen, sonst teilen sich beide
|
||
// Bilder eine Bearbeitung.
|
||
slideEl.querySelectorAll(".mock-2").forEach(n => { n.dataset.edit = "mock-2"; });
|
||
}
|
||
|
||
function applyEdits(slideEl, i) {
|
||
const set = edits[i] || {};
|
||
slideEl.querySelectorAll("[data-edit]").forEach(n => {
|
||
const e = set[n.dataset.edit] || {};
|
||
n.style.setProperty("--ex", (e.dx || 0) + "px");
|
||
n.style.setProperty("--ey", (e.dy || 0) + "px");
|
||
const std = STANDARD[n.dataset.edit];
|
||
if (std && std.prop) {
|
||
n.style[std.prop === "font-size" ? "fontSize" : "width"] =
|
||
e.size ? e.size + "px" : "";
|
||
}
|
||
// Entferntes Element: aus dem Fluss nehmen, nicht nur unsichtbar machen.
|
||
n.style.display = e.weg ? "none" : "";
|
||
/* Die Ebene wirkt nur auf positionierten Elementen. Text steht im Fluss,
|
||
deshalb kommt beim Setzen ein relative dazu -- am Layout aendert das
|
||
nichts, macht die Ebene aber erst wirksam. Ohne Wert bleibt alles,
|
||
wie es die Gestaltung vorsieht. */
|
||
if (e.z === undefined || e.z === null || e.z === "") {
|
||
n.style.zIndex = "";
|
||
if (n.dataset.zpos) { n.style.position = ""; delete n.dataset.zpos; }
|
||
} else {
|
||
n.style.zIndex = e.z;
|
||
if (getComputedStyle(n).position === "static") {
|
||
n.style.position = "relative";
|
||
n.dataset.zpos = "1";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Eine Seite = der Inhalt genau einer Slide, unabhängig davon, in welcher
|
||
// Slide sie gerade mitgerendert wird.
|
||
function buildPage(j) {
|
||
const sl = SLIDES[j];
|
||
const page = document.createElement("div");
|
||
page.className = "page";
|
||
page.dataset.j = String(j);
|
||
page.style.left = (j * 1080) + "px";
|
||
|
||
const content = document.createElement("div");
|
||
content.className = "content";
|
||
page.appendChild(content);
|
||
|
||
if (sl.vis && VIS[sl.vis]) {
|
||
const v = document.createElement("div");
|
||
v.className = "vis vis-" + sl.vis;
|
||
/* ?? statt ||: visLeft ist auf Slide 5 bewusst 0, damit die Zeichenflaeche
|
||
an der linken Slide-Kante beginnt und der Pfeil von Slide 4 dort
|
||
ankommt. Mit || waere die 0 als fehlender Wert durchgegangen und die
|
||
Flaeche haette weiter bei 92 gesessen. */
|
||
v.style.cssText =
|
||
"--vis-w:" + (sl.visW ?? 900) + "px;--vis-left:" + (sl.visLeft ?? 92) +
|
||
"px;--vis-bottom:" + (sl.visBottom ?? 96) + "px";
|
||
v.innerHTML = VIS[sl.vis](j, animT(j));
|
||
page.appendChild(v);
|
||
}
|
||
|
||
if (sl.type === "feature" && assets[sl.img]) {
|
||
const mock = document.createElement("div");
|
||
mock.className = "mock";
|
||
if (sl.mockW) mock.style.setProperty("--mock-w", sl.mockW + "px");
|
||
if (sl.mockAR) mock.style.setProperty("--mock-ar", sl.mockAR);
|
||
if (sl.mockLeft) mock.style.setProperty("--mock-left", sl.mockLeft + "px");
|
||
/* Hinterstes Blatt bekommt standardmässig den Screen der nächsten
|
||
Feature-Slide. Das kann danebengehen: der nächste Screen ist
|
||
womöglich stark gezoomt und damit unscharf, und er zeigt Bedienung,
|
||
die zum Feature dieser Slide gar nicht passt. Deshalb ist er über
|
||
"back" pro Slide überschreibbar. */
|
||
const withImg = SLIDES.filter(x => x.img);
|
||
const auto = withImg[(withImg.findIndex(x => x.img === sl.img) + 1) % withImg.length];
|
||
const other = sl.back || { img: auto.img, imgScale: auto.imgScale, imgX: auto.imgX, imgY: auto.imgY };
|
||
mock.innerHTML =
|
||
'<div class="sheet-back"><img alt="" src="' + assets[other.img] +
|
||
'" style="transform:scale(' + other.imgScale + ') translate(' +
|
||
other.imgX + ',' + other.imgY + ')"></div>' +
|
||
'<div class="sheet-mid"></div>' +
|
||
'<div class="mock-inner" style="--img-scale:' + sl.imgScale +
|
||
';--img-x:' + sl.imgX + ';--img-y:' + sl.imgY + '">' +
|
||
'<img class="laptop-shot" alt="" src="' + assets[sl.img] + '"></div>';
|
||
page.appendChild(mock);
|
||
|
||
|
||
if (sl.note) {
|
||
const nt = sl.note;
|
||
const el3 = document.createElement("div");
|
||
el3.className = "note";
|
||
el3.style.cssText =
|
||
"--note-w:" + nt.w + "px;--note-left:" + nt.left +
|
||
"px;--note-bottom:" + nt.bottom + "px;--note-z:" + nt.z +
|
||
";--rot:" + (nt.rot || 0) + "deg";
|
||
el3.innerHTML =
|
||
'<div class="note-head">' + NOTE_MENU_SVG + '</div>' +
|
||
'<div class="note-body">' + nt.text + '</div>' +
|
||
'<div class="note-grip"><i></i><i></i></div>';
|
||
page.appendChild(el3);
|
||
}
|
||
}
|
||
|
||
/* Ein zweites Bild gibt es auf jeder Slide, nicht nur auf Feature-Slides:
|
||
ein importiertes Bild soll auf der gewaehlten Slide landen, egal
|
||
welche das ist. */
|
||
// Ein Bild, das es nicht mehr gibt (geloescht, nicht mitgepusht), darf die
|
||
// Slide nicht mit einem leeren <img> vergiften: das faellt sonst erst im
|
||
// Export auf, und zwar mit "1 Bild(er) nicht geladen".
|
||
if (sl.media2 && assets[sl.media2.img]) {
|
||
const m2 = sl.media2;
|
||
const el2 = document.createElement("div");
|
||
el2.className = "mock mock-2";
|
||
el2.style.cssText =
|
||
"--mock-w:" + m2.w + "px;--mock-ar:" + m2.ar +
|
||
";--mock-left:" + m2.left + "px;--m2-bottom:" + m2.bottom +
|
||
"px;--m2-z:" + m2.z + ";--rot:" + (m2.rot || 0) + "deg";
|
||
el2.innerHTML =
|
||
'<div class="mock-inner"><img class="laptop-shot" alt="" src="' +
|
||
assets[m2.img] + '"></div>';
|
||
page.appendChild(el2);
|
||
}
|
||
|
||
renderPage(page, j);
|
||
return page;
|
||
}
|
||
|
||
/* ═══ Animation: Steuerung ════════════════════════════════════════════════
|
||
Eine Tuer fuer beide Wege. Das Bauskript setzt den Fortschritt und
|
||
schiesst ab, die Vorschau in der Seite laesst ihn laufen -- was hier
|
||
durchgeht, ist hinterher im Video zu sehen.
|
||
|
||
Neu gezeichnet wird nur die Zeichenflaeche, nicht das ganze Blatt: bei
|
||
150 Bildern macht das den Unterschied zwischen Minuten und einer
|
||
Viertelstunde. */
|
||
|
||
/* Das Blatt einer Animation. Es entsteht wie eine Slide, nur ohne Streifen,
|
||
ohne Nachbarn und ohne Bearbeiten -- eine Animation wird nicht im Canvas
|
||
geschoben, sie ist ein eigenes Stueck. Hintergrund, Variante und Muster
|
||
kommen von der ersten Slide, damit sie im Carousel dazugehoert. */
|
||
function animSlideBauen(i) {
|
||
const a = ANIMS[i];
|
||
const el = document.createElement("div");
|
||
el.className = "slide";
|
||
el.dataset.animi = String(i);
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = "gerade";
|
||
el.dataset.bg = (bgState[0] || {}).bg || "formen";
|
||
el.dataset.int = (bgState[0] || {}).int || "dezent";
|
||
el.dataset.layout = "standard";
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.raster = rasterAn;
|
||
el.dataset.mitte = textMitte;
|
||
el.dataset.frame = frameStyle;
|
||
el.dataset.editmode = "aus";
|
||
el.style.setProperty("--slide-i", 0);
|
||
el.innerHTML = bgHtml();
|
||
|
||
const sheet = document.createElement("div");
|
||
sheet.className = "sheet";
|
||
sheet.style.setProperty("--sheet-w", "1080px");
|
||
|
||
const page = document.createElement("div");
|
||
page.className = "page";
|
||
page.dataset.layout = "standard";
|
||
page.style.left = "0px";
|
||
|
||
const content = document.createElement("div");
|
||
content.className = "content";
|
||
content.innerHTML = contentHtml(a, 0);
|
||
page.appendChild(content);
|
||
|
||
const v = document.createElement("div");
|
||
v.className = "vis vis-" + a.vis;
|
||
v.style.cssText =
|
||
"--vis-w:" + (a.visW ?? 900) + "px;--vis-left:" + (a.visLeft ?? 92) +
|
||
"px;--vis-bottom:" + (a.visBottom ?? 96) + "px";
|
||
v.innerHTML = VIS[a.vis](i, animT(i));
|
||
page.appendChild(v);
|
||
|
||
sheet.appendChild(page);
|
||
el.appendChild(sheet);
|
||
return el;
|
||
}
|
||
|
||
function animZeichnen(i) {
|
||
const a = ANIMS[i];
|
||
if (!a || !VIS[a.vis]) return;
|
||
document.querySelectorAll('.slide[data-animi="' + i + '"] .vis')
|
||
.forEach(v => { v.innerHTML = VIS[a.vis](i, animT(i)); });
|
||
}
|
||
|
||
window.ANIM_LISTE = () => ANIMS.map((a, i) =>
|
||
({ i, key: a.key, dauer: a.dauer || 0, titel: a.eyebrow || a.key }));
|
||
|
||
window.animSetzen = (i, t) => {
|
||
// Number.isFinite faengt NaN ab, bevor es im Stand landet.
|
||
ANIM_T[i] = Number.isFinite(t) ? Math.max(0, Math.min(1, t)) : 0;
|
||
animZeichnen(i);
|
||
const s = document.querySelector('input[data-animschieber="' + i + '"]');
|
||
if (s && document.activeElement !== s) s.value = String(Math.round(ANIM_T[i] * 1000));
|
||
return true;
|
||
};
|
||
|
||
/* Die Vorschau laeuft in derselben Geschwindigkeit wie das spaetere Video
|
||
und wiederholt sich, damit man sieht, ob der Stoss am Ende sitzt -- im
|
||
Carousel laeuft so ein Element in Schleife. */
|
||
let animLaeuft = null;
|
||
function animAbspielen(i, knopf) {
|
||
if (animLaeuft) {
|
||
cancelAnimationFrame(animLaeuft.rahmen);
|
||
const alt = animLaeuft.knopf;
|
||
animLaeuft = null;
|
||
if (alt) alt.textContent = "Abspielen";
|
||
if (alt === knopf) return;
|
||
}
|
||
const dauer = (ANIMS[i].dauer || 4) * 1000;
|
||
/* Weiter, wo der Regler steht -- und bei 0 anfangen, wenn er noch nie
|
||
bewegt wurde. Ohne das || 0 ist ANIM_T[i] beim ersten Klick undefined,
|
||
start wird NaN, der Fortschritt wird NaN, und animT() liefert wegen
|
||
`|| 0` brav wieder 0: die Vorschau stand still, ohne dass irgendwo ein
|
||
Fehler auftauchte. */
|
||
const start = performance.now() - (ANIM_T[i] || 0) * dauer;
|
||
knopf.textContent = "Anhalten";
|
||
const schritt = jetzt => {
|
||
window.animSetzen(i, ((jetzt - start) % dauer) / dauer);
|
||
animLaeuft = { rahmen: requestAnimationFrame(schritt), knopf };
|
||
};
|
||
animLaeuft = { rahmen: requestAnimationFrame(schritt), knopf };
|
||
}
|
||
|
||
/* Der Export. Ein Browser kann kein MP4 schreiben, also stoesst die Seite
|
||
den Bau auf dem Server an und fragt danach den Stand ab. Bewusst getrennt
|
||
vom Bildexport: die Animation ist ein eigenes Element im Carousel und
|
||
soll auch einzeln herauskommen. */
|
||
async function animExport(i, knopf, zeile) {
|
||
const a = ANIMS[i];
|
||
knopf.disabled = true;
|
||
const alt = knopf.textContent;
|
||
knopf.textContent = "baut …";
|
||
zeile.textContent = "Chrome startet …";
|
||
zeile.dataset.ton = "";
|
||
try {
|
||
const an = await fetch("/anim", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ projekt: PROJECT, key: a.key }),
|
||
});
|
||
if (!an.ok) throw new Error("HTTP " + an.status + " — läuft serve.py?");
|
||
const { id } = await an.json();
|
||
for (;;) {
|
||
await new Promise(r => setTimeout(r, 1200));
|
||
const st = await (await fetch("/anim?id=" + encodeURIComponent(id))).json();
|
||
if (st.zeile) zeile.textContent = st.zeile;
|
||
if (st.laeuft) continue;
|
||
if (st.fehler) throw new Error(st.fehler);
|
||
zeile.textContent = st.pfad + " · " + st.groesse;
|
||
zeile.dataset.ton = "ok";
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
zeile.textContent = String(e.message || e);
|
||
zeile.dataset.ton = "err";
|
||
} finally {
|
||
knopf.disabled = false;
|
||
knopf.textContent = alt;
|
||
}
|
||
}
|
||
|
||
/* Der Bereich unter dem Canvas. Er zeigt jede Animation als eigenes Blatt
|
||
in Vorschau, nicht als Eintrag in einer Liste: was hier laeuft, ist
|
||
dasselbe Blatt, das hinterher im Video steht. */
|
||
const ANIM_OFFEN = "postgrid-anim-" + PROJECT;
|
||
const ANIM_PFEIL =
|
||
'<svg class="animpfeil" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||
'stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||
'<path d="m9 18 6-6-6-6"/></svg>';
|
||
|
||
function animLeisteBauen() {
|
||
const liste = window.ANIM_LISTE();
|
||
let leiste = document.getElementById("animleiste");
|
||
if (!leiste) {
|
||
/* Scroller und Fussleiste liegen absolut in der Buehne. Ein Geschwister
|
||
danach landete deshalb oben im Fluss und lag ueber dem Streifen. Also
|
||
wandern beide in eine eigene Flaeche, und die Leiste kommt als zweite
|
||
Zeile darunter -- im selben Fenster, unter dem Canvas. */
|
||
const buehne = document.getElementById("stage");
|
||
if (buehne && !document.getElementById("stageflaeche")) {
|
||
const flaeche = document.createElement("div");
|
||
flaeche.id = "stageflaeche";
|
||
while (buehne.firstChild) flaeche.appendChild(buehne.firstChild);
|
||
buehne.appendChild(flaeche);
|
||
}
|
||
leiste = document.createElement("details");
|
||
leiste.id = "animleiste";
|
||
/* Ob sie offen ist, ist eine Vorliebe und kein Projektstand: sie
|
||
gehoert in den Browser und nicht in state/<projekt>.json, das
|
||
gepusht wird und wo sie bei jedem einen Diff machen wuerde. */
|
||
leiste.open = localStorage.getItem(ANIM_OFFEN) !== "zu";
|
||
leiste.addEventListener("toggle", () => {
|
||
localStorage.setItem(ANIM_OFFEN, leiste.open ? "auf" : "zu");
|
||
});
|
||
(buehne || document.body).appendChild(leiste);
|
||
}
|
||
|
||
const kopf = document.createElement("summary");
|
||
kopf.className = "animkopf";
|
||
kopf.innerHTML = ANIM_PFEIL + "<b>Animation</b><span>" +
|
||
(liste.length
|
||
? liste.length + (liste.length === 1 ? " Element" : " Elemente") +
|
||
" · 1080 × 1350 · 30 fps · MP4, getrennt vom Bildexport"
|
||
: "noch keine in diesem Projekt") + "</span>";
|
||
leiste.innerHTML = "";
|
||
leiste.appendChild(kopf);
|
||
|
||
if (!liste.length) {
|
||
leiste.insertAdjacentHTML("beforeend",
|
||
'<p class="animhin">Eine Animation entsteht als Eintrag in <code>ANIMS</code>: ' +
|
||
'dieselben Felder wie eine Slide, dazu <code>dauer</code> in Sekunden. Ihre ' +
|
||
'Zeichenfunktion bekommt einen zweiten Wert <code>t</code> zwischen 0 und 1 und ' +
|
||
'zeichnet daraus den Stand. Sie liegt bewusst neben <code>SLIDES</code> und ' +
|
||
'nicht darin: der Bildexport lässt sie in Ruhe.</p>');
|
||
return;
|
||
}
|
||
|
||
const reihe = document.createElement("div");
|
||
reihe.className = "animreihe";
|
||
liste.forEach(a => {
|
||
const karte = document.createElement("article");
|
||
karte.className = "animkarte";
|
||
|
||
const buehne = document.createElement("div");
|
||
buehne.className = "animbuehne";
|
||
buehne.appendChild(animSlideBauen(a.i));
|
||
karte.appendChild(buehne);
|
||
|
||
const rechts = document.createElement("div");
|
||
rechts.className = "animrechts";
|
||
rechts.innerHTML =
|
||
'<div class="animtitel">' + a.titel + '</div>' +
|
||
'<div class="animwerte">' + a.dauer.toFixed(1) + " s · " +
|
||
Math.round(a.dauer * 30) + " Bilder · 1080 × 1350</div>" +
|
||
'<input class="animschieber" type="range" min="0" max="1000" value="0" ' +
|
||
'data-animschieber="' + a.i + '" aria-label="Zeitpunkt">' +
|
||
'<div class="animknoepfe">' +
|
||
'<button type="button" class="animspiel">Abspielen</button>' +
|
||
'<button type="button" class="animexport">MP4 exportieren</button>' +
|
||
"</div>" +
|
||
'<p class="animzeile"><code>node animation.mjs ' + PROJECT + ".html " +
|
||
a.key + "</code></p>";
|
||
karte.appendChild(rechts);
|
||
reihe.appendChild(karte);
|
||
|
||
const zeile = rechts.querySelector(".animzeile");
|
||
rechts.querySelector(".animspiel")
|
||
.addEventListener("click", e => animAbspielen(a.i, e.currentTarget));
|
||
rechts.querySelector(".animexport")
|
||
.addEventListener("click", e => animExport(a.i, e.currentTarget, zeile));
|
||
rechts.querySelector(".animschieber").addEventListener("input", e => {
|
||
if (animLaeuft) {
|
||
cancelAnimationFrame(animLaeuft.rahmen);
|
||
if (animLaeuft.knopf) animLaeuft.knopf.textContent = "Abspielen";
|
||
animLaeuft = null;
|
||
}
|
||
window.animSetzen(a.i, +e.currentTarget.value / 1000);
|
||
});
|
||
});
|
||
leiste.appendChild(reihe);
|
||
}
|
||
|
||
/* Der Rendermodus fuer animation.mjs: alles weg bis auf das eine Blatt.
|
||
Aufgebaut wird es erst hier, nach dem Laden von Schrift und Marken --
|
||
sonst misst das Bauskript eine halbfertige Seite. */
|
||
function animNurModus() {
|
||
if (document.documentElement.dataset.nur !== "anim") return;
|
||
const key = new URLSearchParams(location.search).get("k");
|
||
const i = ANIMS.findIndex(a => a.key === key);
|
||
document.body.innerHTML = "";
|
||
if (i < 0) return;
|
||
document.body.appendChild(animSlideBauen(i));
|
||
}
|
||
|
||
function renderPage(page, j) {
|
||
page.querySelector(".content").innerHTML = contentHtml(SLIDES[j], j);
|
||
if (SLIDES[j].type === "title") page.classList.add("title");
|
||
// Das Layout hängt an der Seite, damit eine Titel-Seite auch in einer
|
||
// Nachbar-Slide korrekt aussieht.
|
||
page.dataset.layout = layoutState[j];
|
||
/* Der Layout-Schalter ruft rebuildPage. Das erneuerte bisher nur den
|
||
Textblock, nicht die Visualisierung -- ein Umschalten der Helmlage waere
|
||
dadurch wirkungslos geblieben. Der Zufallskeim ist gesetzt, das erneute
|
||
Zeichnen liefert also denselben Strich. */
|
||
const vv = page.querySelector(".vis");
|
||
if (vv && SLIDES[j].vis && VIS[SLIDES[j].vis]) vv.innerHTML = VIS[SLIDES[j].vis](j, animT(j));
|
||
tagEditable(page);
|
||
applyEdits(page, j);
|
||
}
|
||
|
||
function buildSlide(s, i) {
|
||
const el = document.createElement("div");
|
||
el.className = "slide" + (s.type === "title" ? " title" : "");
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.frame = frameStyle;
|
||
el.dataset.editmode = editMode;
|
||
el.style.setProperty("--slide-i", i);
|
||
el.innerHTML = BG_HTML;
|
||
|
||
/* Jede Slide traegt das vollstaendige Blatt, nicht nur ihre Nachbarn.
|
||
Vorher waren es i-1 bis i+1, und ein Element liess sich damit genau eine
|
||
Slide weit schieben, danach war es abgeschnitten. Der Post soll aber eine
|
||
durchgehende Flaeche sein. Der Preis sind n mal n Seiten statt n mal
|
||
drei -- bei einem Carousel dieser Groesse nicht spuerbar. */
|
||
const sheet = document.createElement("div");
|
||
sheet.className = "sheet";
|
||
for (let j = 0; j < SLIDES.length; j++) {
|
||
sheet.appendChild(buildPage(j));
|
||
}
|
||
el.appendChild(sheet);
|
||
// Über allem, damit die Linien auch auf dem Mockup lesbar bleiben.
|
||
el.insertAdjacentHTML("beforeend",
|
||
'<div class="guides"><div class="grid"></div><div class="safe"></div>' +
|
||
'<div class="mid mid-v"></div><div class="mid mid-h"></div>' +
|
||
'<div class="ax ax-v"></div><div class="ax ax-h"></div></div>');
|
||
return el;
|
||
}
|
||
|
||
/* ─── Hilfslinien ────────────────────────────────────────────────────────
|
||
Die Linien gehören zur Slide, nicht zum Canvas: gemessen wird deshalb
|
||
gegen deren Rechteck. Der Maßstab kürzt sich dabei heraus, die Werte sind
|
||
also unabhängig vom Zoom. */
|
||
const SNAP = 4; // px in Slide-Koordinaten
|
||
let guideSlide = null;
|
||
|
||
function showGuides(slideEl) {
|
||
guideSlide = slideEl;
|
||
slideEl.dataset.guides = "an";
|
||
}
|
||
|
||
function hideGuides() {
|
||
if (!guideSlide) return;
|
||
delete guideSlide.dataset.guides;
|
||
guideSlide.querySelectorAll(".guides .on").forEach(n => n.classList.remove("on"));
|
||
guideSlide = null;
|
||
}
|
||
|
||
function updateGuides(node) {
|
||
if (!guideSlide) return;
|
||
const s = guideSlide.getBoundingClientRect();
|
||
const n = node.getBoundingClientRect();
|
||
if (!s.width || !n.width) return;
|
||
const k = s.width / 1080;
|
||
const cx = (n.left + n.width / 2 - s.left) / k;
|
||
const cy = (n.top + n.height / 2 - s.top) / k;
|
||
const g = guideSlide.querySelector(".guides");
|
||
g.querySelector(".mid-v").style.left = cx + "px";
|
||
g.querySelector(".mid-h").style.top = cy + "px";
|
||
g.querySelector(".ax-v").classList.toggle("on", Math.abs(cx - 540) <= SNAP);
|
||
g.querySelector(".ax-h").classList.toggle("on", Math.abs(cy - 675) <= SNAP);
|
||
}
|
||
|
||
/* Every asset is inlined as a data URL once, before the deck is built.
|
||
Same-origin already satisfies the canvas, but inlining removes every fetch
|
||
and timing failure mode from the export path, and it lets a layout switch
|
||
re-render without touching the network. */
|
||
async function preloadAssets() {
|
||
const paths = ["./assets/brand/logo.svg", "./assets/brand/logo-white.svg"]
|
||
.concat(SLIDES.filter(s => s.img).map(s => s.img))
|
||
.concat(SLIDES.filter(s => s.back).map(s => s.back.img))
|
||
.concat(SLIDES.filter(s => s.media2).map(s => s.media2.img));
|
||
// Noahs Karte von der Ueber-uns-Seite. Zwei Bilder, weil die Karte die
|
||
// Person freigestellt vor ihren eigenen Hintergrund legt.
|
||
paths.push("./assets/team/noah-bg.webp", "./assets/team/noah-person.webp");
|
||
/* Alle Screens, nicht nur die gerade benutzten: eine selbst angelegte
|
||
Slide kann jeden davon waehlen, und ein Bild, das erst beim Auswaehlen
|
||
geladen wuerde, fehlt im Export. */
|
||
BILDER.forEach(sc => paths.push(sc.pfad));
|
||
// Optional: the layout falls back to a hint if the file is not there yet.
|
||
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"]);
|
||
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png");
|
||
for (const path of [...new Set(paths)]) {
|
||
/* no-cache statt no-store: der Browser behaelt seine Kopie und fragt
|
||
nur nach, ob sie noch stimmt. Ein ausgetauschtes Bild kommt genauso
|
||
sofort an, ein unveraendertes kostet ein 304 statt seiner vollen
|
||
Groesse. Mit no-store lud jeder Reload alle Assets neu -- ueber
|
||
uebersicht.html, die sechs Projekte als iframes laedt, waren das
|
||
38 MB und 21 Sekunden. */
|
||
const res = await fetch(path, { cache: "no-cache" }).catch(() => null);
|
||
if (!res || !res.ok) {
|
||
if (optional.has(path)) continue;
|
||
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
|
||
}
|
||
const blob = await res.blob();
|
||
assets[path] = await new Promise((ok, fail) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => ok(fr.result);
|
||
fr.onerror = fail;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
}
|
||
|
||
function press(id, attr, value) {
|
||
document.querySelectorAll("#" + id + " button").forEach(b =>
|
||
b.setAttribute("aria-pressed", String(b.dataset[attr] === value)));
|
||
}
|
||
|
||
function applyAll() {
|
||
deck.style.background = variant === "hell" ? "#FFFFFF" : "#30313D";
|
||
deck.querySelectorAll(".slide").forEach((el, i) => {
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.frame = frameStyle;
|
||
if (SLIDES[i].type === "title") rebuildPage(i);
|
||
el.dataset.editmode = editMode;
|
||
});
|
||
}
|
||
|
||
// The layout catalogue differs per slide, so the buttons are rebuilt on every
|
||
// slide change rather than just re-pressed.
|
||
function syncLayoutButtons() {
|
||
const box = document.getElementById("layouts");
|
||
box.innerHTML = "";
|
||
const cat = SLIDES[current].layouts;
|
||
box.className = "opts" + (cat.length === 1 ? " one" : "");
|
||
cat.forEach(l => {
|
||
const b = document.createElement("button");
|
||
b.dataset.l = l.id;
|
||
b.textContent = l.label;
|
||
b.setAttribute("aria-pressed", String(l.id === layoutState[current]));
|
||
box.appendChild(b);
|
||
});
|
||
}
|
||
|
||
// Reflect the active slide's own ground in the panel.
|
||
function syncBgButtons() {
|
||
press("ints", "i", bgState[current].int);
|
||
}
|
||
|
||
function show(i) {
|
||
current = (i + SLIDES.length) % SLIDES.length;
|
||
saveState();
|
||
deck.querySelectorAll(".holder").forEach((h, n) =>
|
||
h.classList.toggle("sel", n === current));
|
||
document.querySelectorAll("#slides-nav button").forEach((b, n) =>
|
||
b.setAttribute("aria-pressed", String(n === current)));
|
||
syncBgButtons();
|
||
syncLayoutButtons();
|
||
syncInhalt();
|
||
fitStrip();
|
||
metaEl.textContent = view === "streifen"
|
||
? "Slide " + (current + 1) + " ausgewählt. Klick auf eine Slide wechselt die Auswahl"
|
||
: "Slide " + (current + 1) + " von " + SLIDES.length;
|
||
}
|
||
|
||
/* Copy grows, the mockup does not: once the text block reaches the frame the
|
||
slide silently breaks. Rather than discover that in an export, measure it
|
||
and say so. */
|
||
function checkCollisions() {
|
||
const problems = [];
|
||
deck.querySelectorAll(".holder").forEach((holder, i) => {
|
||
const own = holder.querySelector('.page[data-j="' + i + '"]');
|
||
if (!own) return;
|
||
const mock = own.querySelector(".mock");
|
||
if (!mock) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const content = own.querySelector(".content");
|
||
const gap = mock.offsetTop - (content.offsetTop + content.offsetHeight);
|
||
holder.style.display = restore;
|
||
if (gap < 24) problems.push("Slide " + (i + 1) + " (" + SLIDES[i].eyebrow + "): " + Math.round(gap) + "px");
|
||
});
|
||
if (problems.length) {
|
||
console.warn("Text kollidiert mit dem Mockup:\n" + problems.join("\n"));
|
||
setStatus("Achtung: Text zu nah am Mockup (" + problems.length + "x), siehe Konsole", "err");
|
||
}
|
||
return problems.length === 0;
|
||
}
|
||
|
||
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
|
||
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
|
||
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
|
||
erzeugt so keine zusätzlichen Details, nur weichere Kanten. Genau deshalb
|
||
wirkte 3x nicht schärfer als 1x.
|
||
|
||
Der Ausweg: Das SVG bekommt gleich die Zielgröße über width/height, die
|
||
Slide behält per style ihre echten 1080 x 1350 und wird darin skaliert.
|
||
Die Bibliothek setzt style nach width/height, das Überschreiben greift.
|
||
pixelRatio bleibt 1, weil die Vergrößerung schon im SVG steckt. */
|
||
function renderOptions() {
|
||
const k = superSample;
|
||
return {
|
||
width: 1080 * k,
|
||
height: 1350 * k,
|
||
pixelRatio: 1,
|
||
fontEmbedCSS: fontEmbedCss,
|
||
style: {
|
||
width: "1080px",
|
||
height: "1350px",
|
||
transform: k === 1 ? "none" : "scale(" + k + ")",
|
||
transformOrigin: "top left",
|
||
},
|
||
};
|
||
}
|
||
|
||
/* The browser download route was unreliable here: a data: URL opened a blank
|
||
tab, a blob: URL opened the image in a tab, both times ignoring the download
|
||
attribute. Since this only runs against the local server, the bytes go to
|
||
/save instead and land in the Downloads folder directly. */
|
||
async function downloadBlob(blob, filename) {
|
||
const res = await fetch("/save", {
|
||
method: "POST",
|
||
headers: { "X-Filename": filename },
|
||
body: blob,
|
||
});
|
||
if (!res.ok) throw new Error("Speichern fehlgeschlagen: HTTP " + res.status);
|
||
return (await res.json()).path;
|
||
}
|
||
|
||
// Numeric prefix keeps the upload order intact in the file picker.
|
||
const fileName = i => String(i + 1).padStart(2, "0") + "-" + SLIDES[i].key + "-" + variant + ".png";
|
||
|
||
/* html-to-image serialises the slide into a data:image/svg+xml URL and paints
|
||
that as a single image. Raster children of a foreignObject decode
|
||
asynchronously and can miss that paint, which is why a screenshot went
|
||
missing every few exports while the rest of the slide was fine. It is a
|
||
race, so the fix is not to hope but to verify: render, check that every
|
||
screenshot area actually carries pixels, and repeat if it does not. */
|
||
/* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
|
||
Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
|
||
Export ohne Macbook unbemerkt. */
|
||
const SHOT_BOXES =
|
||
".mock-inner, .laptop-screen, .hex-shot, .szene-dev, .laptop-frame";
|
||
const MAX_TRIES = 5;
|
||
// Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
|
||
// abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
|
||
const LEER_SPANNE = 6;
|
||
|
||
/* Die vier Ecken eines Kindes in Slide-Koordinaten.
|
||
Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
|
||
ein gedrehtes oder perspektivisch gestelltes Bild hat ein Umrechteck, das
|
||
Raster, Hintergrund und Nachbarelemente mit einschliesst. Darin fand die
|
||
Prüfung immer genug Helligkeitsspanne und winkte eine leere Fläche durch —
|
||
genau so kam ein Export ohne Screenshots durch die Kontrolle.
|
||
getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
|
||
vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
|
||
rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
|
||
verschwinden vor dem Rendern wieder. */
|
||
function quadInSlide(el, child) {
|
||
const sr = el.getBoundingClientRect();
|
||
const k = sr.width / 1080;
|
||
let host = child, stell = null;
|
||
/* <img> kann keine Kinder tragen. Also entsteht im Elternknoten ein
|
||
deckungsgleicher Stellvertreter — mit derselben Box UND derselben
|
||
eigenen Transformation. Ohne letztere fehlt genau der Versatz
|
||
verschobener Bilder: .mock-inner > img wird per scale und translate in
|
||
seinem Fenster verschoben, das Viereck läge sonst daneben und die
|
||
Messung geriete auf Rahmen statt auf Bild. */
|
||
if (child.tagName === "IMG") {
|
||
const eltern = child.offsetParent || child.parentElement;
|
||
if (!eltern) return null;
|
||
const cs = getComputedStyle(child);
|
||
stell = document.createElement("div");
|
||
stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
|
||
"pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
|
||
"px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
|
||
"px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
|
||
eltern.appendChild(stell);
|
||
host = stell;
|
||
}
|
||
const bw = host.offsetWidth, bh = host.offsetHeight;
|
||
if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
|
||
// Absolute Marken brauchen einen positionierten Bezug.
|
||
const posVorher = host.style.position;
|
||
if (getComputedStyle(host).position === "static") host.style.position = "relative";
|
||
const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
|
||
const m = document.createElement("div");
|
||
m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
|
||
"height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
|
||
host.appendChild(m);
|
||
return m;
|
||
});
|
||
const pts = marken.map(m => {
|
||
const r = m.getBoundingClientRect();
|
||
return { x: ((r.left + r.width / 2) - sr.left) / k,
|
||
y: ((r.top + r.height / 2) - sr.top) / k };
|
||
});
|
||
marken.forEach(m => m.remove());
|
||
host.style.position = posVorher;
|
||
if (stell) stell.remove();
|
||
return pts;
|
||
}
|
||
|
||
/* A screenshot area is never a flat surface: it carries UI, text and colour.
|
||
If the sampled pixels are all but identical, the image did not make it. */
|
||
/* Ein fehlendes Bild hinterlässt die reine Hintergrundfarbe des Rahmens,
|
||
also eine vollkommen gleichmäßige Fläche. Genau darauf wird geprüft, nicht
|
||
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
|
||
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
|
||
fehlend gemeldet. */
|
||
const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
|
||
y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
|
||
|
||
function quadEinruecken(q, f) {
|
||
const m = quadMitte(q);
|
||
return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
|
||
}
|
||
|
||
function quadFlaeche(q) {
|
||
let s = 0;
|
||
for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
|
||
return Math.abs(s) / 2;
|
||
}
|
||
|
||
function quadEnthaelt(q, p) {
|
||
let pos = 0, neg = 0;
|
||
for (let i = 0; i < 4; i++) {
|
||
const a = q[i], b = q[(i + 1) % 4];
|
||
const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||
if (d > 0) pos++; else if (d < 0) neg++;
|
||
}
|
||
return pos === 0 || neg === 0;
|
||
}
|
||
|
||
/* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
|
||
Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
|
||
größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
|
||
das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
|
||
und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
|
||
umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
|
||
läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
|
||
der beiden Vierecke gelegt und gegen das größere geprüft. */
|
||
function ueberlappPunkte(qBox, qImg) {
|
||
const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
|
||
const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
|
||
const gegen = raster === A ? B : A;
|
||
const pts = [];
|
||
for (let a = 0; a <= 16; a++) {
|
||
for (let b = 0; b <= 16; b++) {
|
||
const u = a / 16, v = b / 16;
|
||
const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
|
||
const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
|
||
const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
|
||
const by = raster[3].y + (raster[2].y - raster[3].y) * u;
|
||
const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
|
||
if (quadEnthaelt(gegen, p)) pts.push(p);
|
||
}
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function spanneAn(lum, pts) {
|
||
let min = 255, max = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < min) min = l;
|
||
if (l > max) max = l;
|
||
n++;
|
||
}
|
||
// Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
|
||
return n < 20 ? 255 : max - min;
|
||
}
|
||
|
||
/* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
|
||
Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
|
||
fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
|
||
täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
|
||
zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
|
||
damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
|
||
function dunkelAnteilVorlage(im) {
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = 48;
|
||
const g = c.getContext("2d", { willReadFrequently: true });
|
||
g.fillStyle = "#fff";
|
||
g.fillRect(0, 0, 48, 48);
|
||
try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
|
||
const d = g.getImageData(0, 0, 48, 48).data;
|
||
let dunkel = 0;
|
||
for (let i = 0; i < d.length; i += 4)
|
||
if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
|
||
return dunkel / (48 * 48);
|
||
}
|
||
|
||
function dunkelAn(lum, pts) {
|
||
let dunkel = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < 140) dunkel++;
|
||
n++;
|
||
}
|
||
return n < 20 ? 1 : dunkel / n;
|
||
}
|
||
|
||
|
||
/* ─── Umlaute im gezeichneten Text ───────────────────────────────────────
|
||
Auf einer Slide stand einmal "Sanitaer" und "Klima / Lueftung". Beides
|
||
kam woertlich aus der Quelle -- im Klickdummy war es dort schludrig
|
||
eingetippt --, und beides las sich im fertigen Beitrag wie ein Fehler,
|
||
weil es einer ist. Gefunden hat es niemand beim Bauen, sondern erst
|
||
jemand, der auf den fertigen Post geschaut hat.
|
||
|
||
Deshalb prueft es jetzt der Export. Ein reines Muster genuegt dafuer
|
||
nicht: Deutsch schreibt "neue", "Dauer", "Michael" voellig zu Recht mit
|
||
ae/oe/ue. Nachgezaehlt ueber den gezeichneten Text aller Projekte sind es
|
||
aber nur eine Handvoll solcher Woerter -- eine Ausnahmeliste ist also
|
||
kurz und trifft trotzdem alles andere.
|
||
|
||
ss ist der umgekehrte Fall: fast immer richtig (dass, Schluss, Adresse,
|
||
Netzanschluss), nur ein paar Woerter brauchen ein scharfes s. Die stehen
|
||
deshalb einzeln als Liste und nicht als Muster.
|
||
|
||
Kanalnamen sind Absicht: "# waermepumpe-mueller" ist ein Handle, und die
|
||
schreiben Chatwerkzeuge klein und ohne Umlaute. */
|
||
/* Woerter, in denen ae/oe/ue kein verkruepppelter Umlaut ist, sondern zwei
|
||
Laute an einer Silbengrenze. Die Eintraege wirken als Wortanfang.
|
||
|
||
Die Liste ist kurz geworden, weil die Regel darunter den groessten Teil
|
||
der Ausnahmen von selbst erledigt: ein ersatzweise geschriebener Umlaut
|
||
steht nie hinter einem Vokal. Vorher standen hier dauer, neue, steuer,
|
||
feuer, bauer -- alle nach demselben Muster, und die Liste waere mit
|
||
jedem Beitrag weitergewachsen. Was bleibt, sind die echten Sonderfaelle:
|
||
"Michael" (ae hinter h), "aktuell" (ue hinter t). */
|
||
const UMLAUT_ERLAUBT = new Set([
|
||
"michael", "aktuell", "museum",
|
||
"waermepumpe-mueller", "waermepumpe-abnahme",
|
||
]);
|
||
const BRAUCHT_SCHARFES_S = new Set([
|
||
"aufmass", "strasse", "groesse", "groesser", "fuss", "fussboden",
|
||
"mass", "masse", "weiss", "heisst", "aussen", "gross", "grosse", "spass",
|
||
]);
|
||
|
||
function umlautFehler(wurzel) {
|
||
const raus = [];
|
||
wurzel.querySelectorAll("text, .headline, .body, .badge").forEach(e => {
|
||
/* Nicht textContent, sondern die Textknoten mit Leerzeichen dazwischen.
|
||
Eine Ueberschrift ist "Formulare selbst bauen.<br><span>Per
|
||
Drag-and-drop.</span>" -- textContent klebt daraus "bauen.Per"
|
||
zusammen, ein Wort, das es nicht gibt. Ein Umbruch und eine
|
||
Elementgrenze sind Wortgrenzen. */
|
||
const stuecke = [];
|
||
const lauf = document.createTreeWalker(e, NodeFilter.SHOW_TEXT);
|
||
for (let k = lauf.nextNode(); k; k = lauf.nextNode()) stuecke.push(k.nodeValue);
|
||
const ganz = stuecke.join(" ");
|
||
/* Geviertstriche gehoeren nicht in unsere Texte. Der Halbgeviertstrich
|
||
als Gedankenstrich ist richtiges Deutsch und bleibt erlaubt; der
|
||
lange Strich ist eine englische Angewohnheit und liest sich auf einer
|
||
deutschen Slide fremd. Er kam nicht aus einer Quelle, sondern beim
|
||
Schreiben mit hinein. */
|
||
if (ganz.indexOf("\u2014") >= 0) {
|
||
const stelle = "Geviertstrich in „" + ganz.trim().slice(0, 40) + "“";
|
||
if (!raus.includes(stelle)) raus.push(stelle);
|
||
}
|
||
ganz.split(/[^A-Za-zÄÖÜäöüß0-9./#@-]+/).forEach(w => {
|
||
if (!w) return;
|
||
const k = w.toLowerCase().replace(/^[@#]+/, "").replace(/[.,;:!?)(]+$/, "");
|
||
if (!k || raus.includes(w)) return;
|
||
/* Mailadressen und Domains sind ausgenommen. Dort ist die
|
||
umlautfreie Schreibung keine Schludrigkeit, sondern Pflicht:
|
||
"buero@muster-shk.de" kann gar kein ue ersetzen. Ohne diese
|
||
Ausnahme meldete Post 8 drei Adressen als Fehler. */
|
||
if (/@/.test(k) || /\.(de|com|net|org|eu|at|ch|io)$/.test(k)) return;
|
||
if ([...UMLAUT_ERLAUBT].some(a => k.startsWith(a))) return;
|
||
/* ae/oe/ue nur dann als verunglueckter Umlaut, wenn kein Vokal
|
||
davorsteht. Ein ae, oe oder ue, das fuer ae, oe oder ue steht,
|
||
folgt immer einem Konsonanten oder steht am Wortanfang: "fuer",
|
||
"ueber", "gruen", "Sanitaer", "Lueftung". Hinter einem Vokal sind
|
||
es dagegen immer zwei eigene Laute: bauen, Bauer, neue, Dauer,
|
||
Steuer, Feuer, Mauer, genauer. Die Regel ersetzt sechs Eintraege
|
||
in UMLAUT_ERLAUBT und alle, die dort noch gelandet waeren. */
|
||
if (/(^|[^aeiou])(ae|oe|ue)/.test(k) || BRAUCHT_SCHARFES_S.has(k)) raus.push(w);
|
||
});
|
||
});
|
||
return raus;
|
||
}
|
||
|
||
/* Fuer animation.mjs: das Bewegtbild laeuft nicht durch renderSlide und
|
||
braucht deshalb eine eigene Tuer zu derselben Pruefung. */
|
||
window.TEXT_PRUEFUNG = () => umlautFehler(document);
|
||
|
||
async function renderSlide(i) {
|
||
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
|
||
// stehen, landeten sie im Bild.
|
||
clearTimeout(nudgeTimer); hideGuides();
|
||
const holder = deck.querySelectorAll(".holder")[i];
|
||
const el = holder.querySelector(".slide");
|
||
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
|
||
// Rendern muss der Holder kurz sichtbar sein.
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
// Auswahlrahmen und Hover-Kontur sind Werkzeug, kein Inhalt: für den Export
|
||
// abschalten, sonst wird die gelbe Umrandung mitgerendert.
|
||
const modeBefore = el.dataset.editmode;
|
||
el.dataset.editmode = "aus";
|
||
try {
|
||
await document.fonts.ready;
|
||
/* Den Text pruefen, bevor gerendert wird. Lieber ein Abbruch mit
|
||
klarer Meldung als ein fertiges PNG mit "Sanitaer" darauf. */
|
||
const uf = umlautFehler(el);
|
||
if (uf.length) throw new Error("Slide " + (i + 1) + ": " + uf.join(", ") +
|
||
". Fehlt ein Umlaut, schreib ihn; ist das Wort so richtig, gehoert es " +
|
||
"in UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
|
||
const imgs = [...el.querySelectorAll("img")];
|
||
await Promise.all(imgs.map(im =>
|
||
im.complete ? im.decode().catch(() => {})
|
||
: new Promise(r => { im.onload = r; im.onerror = r; })));
|
||
const broken = imgs.filter(im => !im.naturalWidth);
|
||
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
|
||
|
||
/* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
|
||
mit dem aus der Vorlage geeichten Erwartungswert. */
|
||
const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
|
||
// Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
|
||
const rahmen = c.tagName === "IMG";
|
||
const bild = rahmen ? c : c.querySelector("img");
|
||
if (!bild) return null;
|
||
const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
|
||
const qImg = quadInSlide(el, bild);
|
||
if (!qBox || !qImg) return null;
|
||
return {
|
||
name: c.className || c.tagName.toLowerCase(),
|
||
pts: ueberlappPunkte(qBox, qImg),
|
||
rahmen,
|
||
erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
|
||
/* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
|
||
das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
|
||
— das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
|
||
Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
|
||
obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
|
||
aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
|
||
kleiner rechnen kann jedes Zielsystem selbst. */
|
||
const canvas = await htmlToImage.toCanvas(el, renderOptions());
|
||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||
// Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
|
||
// auf die tatsächliche Ausgabegröße umgerechnet.
|
||
const AW = canvas.width, AH = canvas.height, af = AW / 1080;
|
||
const px = ctx.getImageData(0, 0, AW, AH).data;
|
||
const lum = (x, y) => {
|
||
const sx = Math.round(x * af), sy = Math.round(y * af);
|
||
if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
|
||
const j = (sy * AW + sx) * 4;
|
||
return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
|
||
};
|
||
|
||
const befund = ziele.map(z => {
|
||
const spanne = spanneAn(lum, z.pts);
|
||
// Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
|
||
let fehlt = spanne <= LEER_SPANNE;
|
||
let dunkel = null;
|
||
// Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
|
||
// der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
|
||
// ankommt, gemessen gegen die Vorlage.
|
||
if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
|
||
dunkel = dunkelAn(lum, z.pts);
|
||
if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
|
||
}
|
||
return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
|
||
});
|
||
const fehlend = befund.filter(b => b.fehlt);
|
||
|
||
if (!fehlend.length) {
|
||
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
|
||
if (blob) return blob;
|
||
}
|
||
if (attempt < MAX_TRIES) {
|
||
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
|
||
befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
|
||
(b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
|
||
" erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
|
||
setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
|
||
" fehlte, Versuch " + (attempt + 1) + " ...");
|
||
await new Promise(r => setTimeout(r, 180 * attempt));
|
||
}
|
||
}
|
||
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
|
||
" Versuchen unvollstaendig. Details in der Konsole.");
|
||
} finally {
|
||
el.dataset.editmode = modeBefore;
|
||
holder.style.display = restore;
|
||
}
|
||
}
|
||
|
||
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
|
||
// sichtbar ist, was tatsächlich auf der Platte landet.
|
||
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
|
||
|
||
async function downloadOne() {
|
||
setStatus("Rendere Slide " + (current + 1) + " …");
|
||
downloadBlob(await renderSlide(current), fileName(current));
|
||
setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
async function downloadAll() {
|
||
const zip = new JSZip();
|
||
for (let i = 0; i < SLIDES.length; i++) {
|
||
setStatus("Rendere Slide " + (i + 1) + " von " + SLIDES.length + " …");
|
||
zip.file(fileName(i), await renderSlide(i));
|
||
}
|
||
show(current);
|
||
setStatus("Packe ZIP …");
|
||
/* Der Name kommt aus PROJECT und nicht aus einer festen Zeichenkette.
|
||
Vorher stand hier "hivegrid-post1-loesung" -- in allen Projektdateien,
|
||
weil sie voneinander kopiert wurden. Post 2 und Post 3 haben ihre ZIPs
|
||
damit unter dem Namen von Post 1 abgelegt, und im Downloads-Ordner lagen
|
||
drei verschiedene Beitraege unter einem Namen. */
|
||
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
|
||
"hivegrid-" + PROJECT + "-" + variant + ".zip");
|
||
setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
const guard = fn => () => fn().catch(err => {
|
||
console.error(err);
|
||
setStatus("Fehler: " + err.message, "err");
|
||
});
|
||
|
||
/* ─── Slides verwalten ────────────────────────────────────────────────────
|
||
Die Felder pro Slide (Verschiebungen, Muster, Layout) haengen an der
|
||
Anzahl und muessen bei jeder Aenderung im Gleichschritt mitwandern. Das
|
||
laeuft ueber slideListe als Bezugsgroesse, nicht ueber SLIDES: SLIDES
|
||
wird daraus jedes Mal neu berechnet. */
|
||
function passeFelderAn() {
|
||
const n = SLIDES.length;
|
||
while (edits.length < n) edits.push({});
|
||
edits.length = n;
|
||
while (bgState.length < n) bgState.push({ bg: "formen", int: "dezent" });
|
||
bgState.length = n;
|
||
while (layoutState.length < n) layoutState.push(SLIDES[layoutState.length].layouts[0].id);
|
||
layoutState.length = n;
|
||
// Ein Layout, das es auf dieser Slide nicht gibt, faellt auf das erste
|
||
// zurueck: sonst zeigt eine Feature-Slide ein Titel-Layout.
|
||
layoutState.forEach((id, i) => {
|
||
if (!SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = SLIDES[i].layouts[0].id;
|
||
});
|
||
}
|
||
|
||
function deckNeuBauen() {
|
||
deck.innerHTML = "";
|
||
nav.innerHTML = "";
|
||
buildDeck();
|
||
applyAll();
|
||
applyEditsAll();
|
||
}
|
||
|
||
function anzahlNachziehen() {
|
||
const n = SLIDES.length;
|
||
const kopf = document.getElementById("kopfzeile");
|
||
if (kopf) kopf.textContent =
|
||
(n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × 1350 px";
|
||
const zip = document.getElementById("dl-all");
|
||
if (zip) {
|
||
zip.textContent = "Alle " + n + " Slides als ZIP";
|
||
// Bei einer einzigen Slide ist ein ZIP nur ein Umweg.
|
||
zip.style.display = n > 1 ? "" : "none";
|
||
}
|
||
// Das durchgehende Blatt ist so breit wie der Post lang ist.
|
||
deck.style.setProperty("--sheet-w", (n * 1080) + "px");
|
||
}
|
||
|
||
function nachDeckAenderung(an, meldung) {
|
||
slidesNeuAufbauen();
|
||
passeFelderAn();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
current = Math.max(0, Math.min(an, SLIDES.length - 1));
|
||
show(current);
|
||
anzahlNachziehen();
|
||
syncInhalt();
|
||
syncEditPanel();
|
||
saveState();
|
||
if (meldung) setStatus(meldung, "ok");
|
||
}
|
||
|
||
function slideEinfuegen(eintrag, an) {
|
||
slideListe.splice(an, 0, eintrag);
|
||
edits.splice(an, 0, {});
|
||
bgState.splice(an, 0, kopie(bgState[current] || { bg: "formen", int: "dezent" }));
|
||
slidesNeuAufbauen();
|
||
layoutState.splice(an, 0, SLIDES[an].layouts[0].id);
|
||
}
|
||
|
||
function slideNeu() {
|
||
const an = current + 1;
|
||
slideEinfuegen({ werte: {} }, an);
|
||
nachDeckAenderung(an, "Slide " + (an + 1) + " angelegt");
|
||
}
|
||
|
||
function slideKopieren() {
|
||
const an = current + 1;
|
||
const q = slideListe[current];
|
||
slideEinfuegen({ ref: q.ref, key: q.key, werte: kopie(q.werte || {}) }, an);
|
||
// Auch die Feinarbeit mitnehmen, sonst ist die Kopie nur halb eine.
|
||
edits[an] = kopie(edits[current]);
|
||
bgState[an] = kopie(bgState[current]);
|
||
layoutState[an] = layoutState[current];
|
||
nachDeckAenderung(an, "Slide kopiert");
|
||
}
|
||
|
||
function slideLoeschen() {
|
||
if (SLIDES.length <= 1) { setStatus("Die letzte Slide bleibt stehen", "err"); return; }
|
||
const weg = current;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => a.splice(weg, 1));
|
||
nachDeckAenderung(Math.min(weg, SLIDES.length - 2), "Slide gelöscht");
|
||
}
|
||
|
||
function slideSchieben(um) {
|
||
const ziel = current + um;
|
||
if (ziel < 0 || ziel >= SLIDES.length) return;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => {
|
||
const [x] = a.splice(current, 1);
|
||
a.splice(ziel, 0, x);
|
||
});
|
||
nachDeckAenderung(ziel, "Slide verschoben");
|
||
}
|
||
|
||
/* ─── Inhalt der gewaehlten Slide ─────────────────────────────────────────
|
||
Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht
|
||
aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt
|
||
Aenderungen an der Vorlage im Code weiterhin mit. */
|
||
/* ─── Eigene Bilder ───────────────────────────────────────────────────────
|
||
Die Datei geht unveraendert an den Server und liegt danach unter
|
||
assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere
|
||
ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff
|
||
unbrauchbar. So steht dort nur der Pfad. */
|
||
function bildOptionen() {
|
||
return BILDER.map(b =>
|
||
'<option value="' + b.pfad + '">' +
|
||
(b.ordner === "eigene" ? "★ " : "") + b.name + '</option>').join("");
|
||
}
|
||
|
||
async function bilderNachladen() {
|
||
try {
|
||
const res = await fetch("/bilder", { cache: "no-store" });
|
||
if (!res.ok) return;
|
||
const liste = await res.json();
|
||
if (Array.isArray(liste) && liste.length) BILDER = liste;
|
||
} catch (_) { /* ohne Server bleibt die eingebaute Liste */ }
|
||
}
|
||
|
||
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
|
||
async function bildBereitstellen(pfad) {
|
||
if (assets[pfad]) return true;
|
||
const res = await fetch(pfad, { cache: "no-cache" }).catch(() => null);
|
||
if (!res || !res.ok) return false;
|
||
const blob = await res.blob();
|
||
assets[pfad] = await new Promise((ok, fail) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => ok(fr.result);
|
||
fr.onerror = fail;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async function bildImportieren(datei) {
|
||
const res = await fetch("/upload", {
|
||
method: "POST",
|
||
// Kodiert, damit Umlaute und Leerzeichen den Header nicht sprengen.
|
||
headers: { "X-Filename": encodeURIComponent(datei.name) },
|
||
body: datei,
|
||
});
|
||
if (!res.ok) {
|
||
const w = await res.json().catch(() => ({}));
|
||
/* 404 heisst hier fast immer: der Python-Server laeuft noch in einer
|
||
Fassung ohne /upload. Die Seite neu zu laden hilft dagegen nicht --
|
||
serve.py wird nur beim Start eingelesen. */
|
||
if (res.status === 404) throw new Error("Server kennt /upload nicht · ./serve.sh neu starten");
|
||
throw new Error(w.error || ("HTTP " + res.status));
|
||
}
|
||
const { pfad } = await res.json();
|
||
await bilderNachladen();
|
||
await bildBereitstellen(pfad);
|
||
return pfad;
|
||
}
|
||
|
||
// Masse aus dem Bild selbst: ein Hochformat soll nicht in einem
|
||
// Querformat-Rahmen stehen.
|
||
function seitenverhaeltnis(pfad) {
|
||
return new Promise(ok => {
|
||
const im = new Image();
|
||
im.onload = () => ok(im.naturalWidth / im.naturalHeight);
|
||
im.onerror = () => ok(1.6);
|
||
im.src = assets[pfad] || pfad;
|
||
});
|
||
}
|
||
|
||
/* Ein Element genau auf die Mitte der Slide setzen. Gerechnet wird nicht,
|
||
sondern gemessen: das Blatt ist gepolstert, die Mockups stehen schief im
|
||
Raum, und ein Dreisatz ueber left und bottom laege daneben. Der Versatz
|
||
wandert in die Bearbeitung, ist also nachtraeglich frei verschiebbar. */
|
||
function mittigSetzen(i, key) {
|
||
const node = deck.querySelector(
|
||
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
|
||
if (!node) return;
|
||
const slide = node.closest(".slide");
|
||
if (!slide) return;
|
||
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
|
||
if (!sr.width || !nr.width) return;
|
||
const k = sr.width / 1080;
|
||
const dx = ((nr.left + nr.width / 2) - (sr.left + sr.width / 2)) / k;
|
||
const dy = ((nr.top + nr.height / 2) - (sr.top + sr.height / 2)) / k;
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
set.dx = Math.round((set.dx || 0) - dx);
|
||
set.dy = Math.round((set.dy || 0) - dy);
|
||
applyEditsAll();
|
||
}
|
||
|
||
async function alsZweitesBild(i, pfad, mittig) {
|
||
const ar = await seitenverhaeltnis(pfad);
|
||
const w = werteVon(i);
|
||
const vorher = w.media2 || SLIDES[i].media2 || {};
|
||
w.media2 = {
|
||
img: pfad, w: vorher.w || 480, ar: +ar.toFixed(4),
|
||
left: vorher.left != null ? vorher.left : 300,
|
||
bottom: vorher.bottom != null ? vorher.bottom : 400,
|
||
z: vorher.z || 2, rot: mittig ? 0 : (vorher.rot || 0),
|
||
};
|
||
slidesNeuAufbauen();
|
||
deckNeuBauen();
|
||
show(i);
|
||
// Ein frisch importiertes Bild soll man sehen, nicht suchen.
|
||
if (mittig) {
|
||
edits[i]["mock-2"] = {};
|
||
mittigSetzen(i, "mock-2");
|
||
pick(i, "mock-2");
|
||
}
|
||
saveState();
|
||
}
|
||
|
||
async function dateienAufnehmen(dateien, zielSlide) {
|
||
const bilder = [...dateien].filter(f => /^image\//.test(f.type));
|
||
if (!bilder.length) { setStatus("Keine Bilddatei dabei", "err"); return; }
|
||
setStatus("Importiere " + bilder.length + " Bild(er) …");
|
||
const pfade = [];
|
||
try {
|
||
for (const f of bilder) pfade.push(await bildImportieren(f));
|
||
} catch (err) {
|
||
setStatus("Import fehlgeschlagen: " + err.message, "err");
|
||
return;
|
||
}
|
||
// Beim Ablegen auf einer Slide wandert das erste Bild gleich dorthin. Ueber
|
||
// den Knopf importiert man nur; zugeordnet wird dann in den Feldern.
|
||
/* Ohne Ziel gilt die gewaehlte Slide. Frueher wurde nur importiert und
|
||
die Zuordnung blieb in einem Auswahlfeld liegen -- das Bild war damit
|
||
nirgends zu sehen und der Import wirkte wie ein Fehlschlag. */
|
||
const ziel = typeof zielSlide === "number" ? zielSlide : current;
|
||
await alsZweitesBild(ziel, pfade[0], true);
|
||
syncInhalt();
|
||
setStatus(pfade.length === 1
|
||
? "Bild auf Slide " + (ziel + 1) + " gelegt · mit E verschieben"
|
||
: pfade.length + " Bilder importiert, erstes auf Slide " + (ziel + 1) +
|
||
" gelegt · Rest unter Inhalt", "ok");
|
||
}
|
||
|
||
document.getElementById("bild-waehlen").addEventListener("click",
|
||
() => document.getElementById("bild-datei").click());
|
||
document.getElementById("bild-datei").addEventListener("change", ev => {
|
||
const f = ev.target.files;
|
||
if (f && f.length) dateienAufnehmen(f);
|
||
ev.target.value = ""; // dieselbe Datei soll erneut waehlbar bleiben
|
||
});
|
||
|
||
/* Ablegen auf einer Slide. Der Browser oeffnet eine fallengelassene Datei
|
||
sonst einfach im Tab und der Projektstand waere verloren. */
|
||
(function ablegen() {
|
||
const sc = document.getElementById("scroller");
|
||
const zielAus = e => {
|
||
const h = e.target.closest && e.target.closest(".holder");
|
||
return h ? Number(h.dataset.n) : null;
|
||
};
|
||
["dragenter", "dragover"].forEach(art => sc.addEventListener(art, e => {
|
||
if (!e.dataTransfer || [...e.dataTransfer.types].indexOf("Files") < 0) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
const n = zielAus(e);
|
||
deck.querySelectorAll(".holder").forEach((h, i) => h.classList.toggle("ablegen", i === n));
|
||
}));
|
||
sc.addEventListener("dragleave", e => {
|
||
if (e.relatedTarget && sc.contains(e.relatedTarget)) return;
|
||
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
|
||
});
|
||
sc.addEventListener("drop", e => {
|
||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||
e.preventDefault();
|
||
const n = zielAus(e);
|
||
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
|
||
dateienAufnehmen(e.dataTransfer.files, n == null ? undefined : n);
|
||
});
|
||
// Ausserhalb der Flaeche nichts oeffnen, sondern still ablehnen.
|
||
["dragover", "drop"].forEach(art => window.addEventListener(art, e => {
|
||
if (!sc.contains(e.target)) e.preventDefault();
|
||
}));
|
||
})();
|
||
|
||
function werteVon(i) {
|
||
const e = slideListe[i];
|
||
return (e.werte = e.werte || {});
|
||
}
|
||
|
||
function syncInhalt() {
|
||
const box = document.getElementById("inhalt");
|
||
if (!box) return;
|
||
const s = SLIDES[current];
|
||
const id = current + ":" + (s.type || "");
|
||
if (box.dataset.for !== id) {
|
||
box.dataset.for = id;
|
||
const felder = s.type === "feature"
|
||
? [["eyebrow", "Tag", "input"], ["head", "Überschrift", "area"], ["body", "Fließtext", "area"]]
|
||
: [["head", "Überschrift", "area"], ["sub", "Claim", "area"]];
|
||
box.innerHTML = felder.map(([k, label, art]) =>
|
||
'<label class="inh-label" for="in-' + k + '">' + label + '</label>' +
|
||
(art === "area"
|
||
? '<textarea id="in-' + k + '" data-feld="' + k + '" rows="2"></textarea>'
|
||
: '<input id="in-' + k + '" data-feld="' + k + '" type="text">')
|
||
).join("") +
|
||
(s.type === "feature"
|
||
? '<label class="inh-label" for="in-img">Screenshot</label>' +
|
||
'<select id="in-img" data-feld="img">' + bildOptionen() + '</select>' +
|
||
'<label class="inh-label" for="in-media2">Zweites Bild</label>' +
|
||
'<select id="in-media2" data-feld="media2">' +
|
||
'<option value="">— keins —</option>' + bildOptionen() + '</select>'
|
||
: "") +
|
||
'<div class="hint">*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' +
|
||
(s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '</div>';
|
||
}
|
||
box.querySelectorAll("[data-feld]").forEach(el => {
|
||
if (document.activeElement === el) return;
|
||
const k = el.dataset.feld;
|
||
if (k === "img") el.value = s.img || "";
|
||
else if (k === "media2") el.value = (s.media2 && s.media2.img) || "";
|
||
else el.value = htmlZuText(s[k]);
|
||
});
|
||
}
|
||
|
||
document.getElementById("inhalt").addEventListener("input", ev => {
|
||
const el = ev.target.closest("[data-feld]");
|
||
if (!el) return;
|
||
const k = el.dataset.feld;
|
||
const w = werteVon(current);
|
||
if (k === "media2") {
|
||
if (!el.value) {
|
||
// null statt weglassen: sonst kaeme das Bild der Vorlage zurueck.
|
||
w.media2 = null;
|
||
slidesNeuAufbauen(); deckNeuBauen(); show(current); saveState();
|
||
} else {
|
||
alsZweitesBild(current, el.value);
|
||
}
|
||
return;
|
||
}
|
||
if (k === "img") {
|
||
w.img = el.value;
|
||
// Das Rahmenformat folgt dem Bild, sonst steht ein Hochformat in einem
|
||
// Querformat-Rahmen und laesst links und rechts Flaechen frei.
|
||
const im = new Image();
|
||
im.onload = () => {
|
||
w.mockAR = +(im.naturalWidth / im.naturalHeight).toFixed(4);
|
||
w.imgScale = 1; w.imgX = "0%"; w.imgY = "0%";
|
||
slidesNeuAufbauen();
|
||
// Das Rahmenformat steckt im Aufbau der Seite, nicht in ihren Texten:
|
||
// dafuer reicht rebuildPage nicht, der Halter muss neu entstehen.
|
||
deckNeuBauen();
|
||
show(current);
|
||
saveState();
|
||
};
|
||
im.src = assets[el.value] || el.value;
|
||
return;
|
||
}
|
||
w[k] = textZuHtml(el.value);
|
||
slidesNeuAufbauen();
|
||
// Nur die betroffene Seite neu zeichnen: ein voller Neuaufbau wuerde das
|
||
// Eingabefeld unter den Fingern wegreissen.
|
||
rebuildPage(current);
|
||
applyEditsAll();
|
||
nav.querySelectorAll("button")[current].innerHTML =
|
||
'<span class="num">' + (current + 1) + '</span>' +
|
||
(SLIDES[current].type === "title" ? "Titel" : SLIDES[current].eyebrow);
|
||
saveState();
|
||
});
|
||
|
||
document.getElementById("slide-ops").addEventListener("click", ev => {
|
||
const b = ev.target.closest("button");
|
||
if (!b || stateLocked) return;
|
||
if (b.id === "slide-neu") slideNeu();
|
||
else if (b.id === "slide-kopie") slideKopieren();
|
||
else if (b.id === "slide-weg") slideLoeschen();
|
||
else if (b.id === "slide-links") slideSchieben(-1);
|
||
else if (b.id === "slide-rechts") slideSchieben(1);
|
||
});
|
||
|
||
function buildDeck() {
|
||
SLIDES.forEach((sl, i) => {
|
||
const holder = document.createElement("div");
|
||
holder.className = "holder";
|
||
holder.dataset.n = String(i);
|
||
holder.appendChild(buildSlide(sl, i));
|
||
deck.appendChild(holder);
|
||
});
|
||
SLIDES.forEach((sl, i) => {
|
||
const b = document.createElement("button");
|
||
b.dataset.n = String(i);
|
||
b.setAttribute("aria-pressed", String(i === 0));
|
||
b.innerHTML = '<span class="num">' + (i + 1) + '</span>' +
|
||
(sl.type === "title" ? "Titel" : sl.eyebrow);
|
||
nav.appendChild(b);
|
||
});
|
||
}
|
||
|
||
deck.addEventListener("click", e => {
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
const h = e.target.closest(".holder");
|
||
if (h) show(Number(h.dataset.n));
|
||
});
|
||
|
||
/* Ziehen: Die Vorschau ist skaliert, die Slide nicht. Mausdeltas werden
|
||
deshalb durch den Maßstab geteilt, sonst wandert das Element bei kleiner
|
||
Vorschau um ein Vielfaches. */
|
||
deck.addEventListener("mousedown", e => {
|
||
if (editMode !== "an") return;
|
||
const node = e.target.closest("[data-edit]");
|
||
if (!node) return;
|
||
const holder = node.closest(".holder");
|
||
const page = node.closest(".page");
|
||
const idx = Number(page.dataset.j); // Besitzer-Seite, nicht die angeklickte Slide
|
||
const key = node.dataset.edit;
|
||
e.preventDefault();
|
||
|
||
show(Number(holder.dataset.n));
|
||
pick(idx, key);
|
||
|
||
/* Den Maßstab aus dem lesen, was tatsächlich gezeichnet ist. Das Rechteck
|
||
zu messen geht nicht: .strip ist ein Flex-Kind der schon skalierten
|
||
Hülle, seine Border-Box misst Breite mal Maßstab hoch zwei. Und
|
||
autoFit() neu zu rechnen geht seit dem Element-Feld auch nicht mehr:
|
||
das fährt beim Anklicken ein, macht die Bühne schmaler und ändert den
|
||
Maßstab mitten im Klick. */
|
||
const mSc = /scale\(([-\d.]+)\)/.exec(deck.style.transform || "");
|
||
const k = mSc ? Number(mSc[1]) : (zoom || autoFit());
|
||
const set = (edits[idx][key] = edits[idx][key] || {});
|
||
const startX = e.clientX, startY = e.clientY;
|
||
const baseX = set.dx || 0, baseY = set.dy || 0;
|
||
node.style.cursor = "grabbing";
|
||
showGuides(holder.querySelector(".slide"));
|
||
updateGuides(node);
|
||
|
||
const move = ev => {
|
||
set.dx = Math.round(baseX + (ev.clientX - startX) / k);
|
||
set.dy = Math.round(baseY + (ev.clientY - startY) / k);
|
||
applyEditsAll(); // zieht alle Kopien der Seite mit
|
||
updateGuides(node);
|
||
syncEditPanel();
|
||
};
|
||
const up = () => {
|
||
node.style.cursor = "";
|
||
hideGuides();
|
||
document.removeEventListener("mousemove", move);
|
||
document.removeEventListener("mouseup", up);
|
||
saveState();
|
||
};
|
||
document.addEventListener("mousemove", move);
|
||
document.addEventListener("mouseup", up);
|
||
});
|
||
|
||
function pick(idx, key) {
|
||
picked = { slide: idx, key };
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
deck.querySelectorAll('.page[data-j="' + idx + '"] [data-edit="' + key + '"]')
|
||
.forEach(n => n.classList.add("picked"));
|
||
syncEditPanel();
|
||
}
|
||
|
||
let nudgeTimer = null;
|
||
function nudge(dx, dy) {
|
||
if (!picked) return;
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
set.dx = (set.dx || 0) + dx;
|
||
set.dy = (set.dy || 0) + dy;
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
// Pixelweise schieben braucht dieselbe Rueckmeldung wie das Ziehen, nur
|
||
// soll sie danach von selbst verschwinden.
|
||
const holder = deck.querySelectorAll(".holder")[current];
|
||
const node = holder && holder.querySelector('.page[data-j="' + picked.slide +
|
||
'"] [data-edit="' + picked.key + '"]');
|
||
if (node) {
|
||
showGuides(holder.querySelector(".slide"));
|
||
updateGuides(node);
|
||
clearTimeout(nudgeTimer);
|
||
nudgeTimer = setTimeout(hideGuides, 900);
|
||
}
|
||
saveState();
|
||
}
|
||
|
||
function resize(dir) {
|
||
if (!picked) return;
|
||
const std = STANDARD[picked.key];
|
||
if (!std || !std.prop) return;
|
||
const base = stdSize(picked.key, picked.slide);
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
set.size = Math.max(8, (set.size || base) + dir * stepFor(picked.key, picked.slide));
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
saveState();
|
||
}
|
||
|
||
function resetOne() {
|
||
if (!picked) return;
|
||
delete edits[picked.slide][picked.key];
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
saveState();
|
||
}
|
||
|
||
function resetAll() {
|
||
edits = SLIDES.map(() => ({}));
|
||
applyEditsAll();
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
saveState();
|
||
setStatus("Alle Slides auf Standard zurückgesetzt", "ok");
|
||
}
|
||
|
||
/* X und Y zaehlen ab der Mitte der Slide, nicht ab der Stelle, an der das
|
||
Element von sich aus laege. Nur so bedeuten gleiche Werte bei zwei
|
||
verschiedenen Elementen auch dieselbe Stelle.
|
||
Gemessen wird die OBERE LINKE ECKE, nicht der Mittelpunkt. Eine
|
||
zweizeilige und eine dreizeilige Ueberschrift sind unterschiedlich hoch;
|
||
bei gleichem Mittelpunkt liegen ihre Oberkanten 39 px auseinander und der
|
||
hoehere Block waechst nach oben ins Tag. Ab der ersten Zeile gerechnet
|
||
stimmt es unabhaengig von der Zeilenzahl.
|
||
Gemessen wird die erste sichtbare Kopie: in der Einzelansicht sind die
|
||
uebrigen Holder ausgeblendet und liefern ein leeres Rechteck. */
|
||
function centreOf(slideIdx, key) {
|
||
const nodes = deck.querySelectorAll(
|
||
'.page[data-j="' + slideIdx + '"] [data-edit="' + key + '"]');
|
||
for (const n of nodes) {
|
||
const r = n.getBoundingClientRect();
|
||
if (!r.width) continue;
|
||
const holder = n.closest(".slide");
|
||
const sr = holder && holder.getBoundingClientRect();
|
||
if (!sr || !sr.width) continue;
|
||
const k = sr.width / 1080;
|
||
return {
|
||
x: Math.round((r.left - sr.left) / k - 540),
|
||
y: Math.round((r.top - sr.top) / k - 675),
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* Welches Textfeld der Slide haengt an welchem Element. Nur was hier steht,
|
||
laesst sich im Element-Feld direkt beschriften; der Rest (Logos, Bilder,
|
||
Geraete) traegt keinen Text. */
|
||
const TEXT_ZU_FELD = {
|
||
"badge": "eyebrow", "headline": "head", "body": "body",
|
||
"title-head": "head", "title-sub": "sub", "wabe-claim": "sub",
|
||
};
|
||
|
||
/* Ein geloeschtes Element laesst sich nicht mehr anklicken. Ohne diese
|
||
Liste bliebe nur "Alles auf Standard" oder Rueckgaengig -- es waere also
|
||
praktisch verloren. */
|
||
/* Der Index kommt von aussen: seit jede Slide das ganze Blatt traegt, ist
|
||
die angezeigte Slide nicht mehr zwingend die bearbeitete. */
|
||
function verstecktHtml(i) {
|
||
const versteckt = Object.keys(edits[i] || {})
|
||
.filter(k => (edits[i][k] || {}).weg);
|
||
if (!versteckt.length) return "";
|
||
return '<div class="abschnitt">Gelöscht auf Slide ' + (i + 1) + '</div>' +
|
||
'<div class="opts">' +
|
||
versteckt.map(k => '<button data-zurueck="' + k + '" data-slide="' + i + '">' +
|
||
((STANDARD[k] || {}).label || k) + ' zurück</button>').join("") + '</div>';
|
||
}
|
||
|
||
/* Ausrichten an der Slide. Gemessen statt gerechnet: die Mockups stehen
|
||
perspektivisch im Raum und das Blatt ist gepolstert, ein Dreisatz ueber
|
||
left und top laege daneben. 92 px ist der Rand, den die Gestaltung
|
||
ohnehin haelt (.content). */
|
||
const RAND = 92;
|
||
function ausrichten(i, key, waag, senk) {
|
||
const node = deck.querySelector(
|
||
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
|
||
const slide = node && node.closest(".slide");
|
||
if (!slide) return;
|
||
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
|
||
if (!sr.width || !nr.width) return;
|
||
const k = sr.width / 1080;
|
||
const l = (nr.left - sr.left) / k, o = (nr.top - sr.top) / k;
|
||
const b = nr.width / k, h = nr.height / k;
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
if (waag) {
|
||
const ziel = waag === "links" ? RAND
|
||
: waag === "mitte" ? (1080 - b) / 2
|
||
: 1080 - RAND - b;
|
||
set.dx = Math.round((set.dx || 0) + (ziel - l));
|
||
}
|
||
if (senk) {
|
||
const ziel = senk === "oben" ? RAND
|
||
: senk === "mitte" ? (1350 - h) / 2
|
||
: 1350 - RAND - h;
|
||
set.dy = Math.round((set.dy || 0) + (ziel - o));
|
||
}
|
||
applyEditsAll();
|
||
syncEditPanel();
|
||
saveState();
|
||
}
|
||
|
||
function syncEditPanel() {
|
||
const koerper = document.getElementById("el-inhalt");
|
||
const titel = document.getElementById("el-titel");
|
||
// Nur beim Auf- und Zugehen neu einpassen: sonst springt der Streifen bei
|
||
// jedem Wechsel der Auswahl.
|
||
const vorher = document.body.dataset.elem;
|
||
document.body.dataset.elem = picked ? "an" : "aus";
|
||
const wechsel = vorher !== document.body.dataset.elem;
|
||
if (!picked) { koerper.dataset.for = ""; if (wechsel) fitStrip(); return; }
|
||
|
||
const std = STANDARD[picked.key] || {};
|
||
const e = (edits[picked.slide] || {})[picked.key] || {};
|
||
const base = stdSize(picked.key, picked.slide);
|
||
const feld = TEXT_ZU_FELD[picked.key];
|
||
const id = picked.slide + ":" + picked.key + ":" + (edits[picked.slide][picked.key] || {}).weg;
|
||
// Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier
|
||
// pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern
|
||
// wegreissen.
|
||
if (koerper.dataset.for !== id) {
|
||
koerper.dataset.for = id;
|
||
titel.textContent = (std.label || picked.key);
|
||
koerper.innerHTML =
|
||
'<p class="sub">Slide ' + (picked.slide + 1) + " von " + SLIDES.length + '</p>' +
|
||
(feld
|
||
? '<div class="abschnitt">Text</div>' +
|
||
'<textarea id="f-text" rows="3"></textarea>' +
|
||
'<div class="hint">*Wort* setzt den Akzent, Zeilenumbruch bricht um.</div>'
|
||
: "") +
|
||
'<div class="abschnitt">Maße</div>' +
|
||
'<div class="fields">' +
|
||
(std.prop
|
||
? '<label for="f-size">' + (std.prop === "font-size" ? "Schrift" : "Breite") +
|
||
'</label><input id="f-size" type="number" step="1" style="grid-column:2/5">'
|
||
: "") +
|
||
'<label for="f-dx">X</label><input id="f-dx" type="number" step="1">' +
|
||
'<label for="f-dy">Y</label><input id="f-dy" type="number" step="1">' +
|
||
'<label for="f-z">Ebene</label>' +
|
||
'<input id="f-z" type="number" step="1" placeholder="Standard" style="grid-column:2/5">' +
|
||
'</div>' +
|
||
'<div class="hint">' + (std.prop ? "Standard " + base + " px · " : "") +
|
||
'Obere linke Ecke, 0 / 0 ist die Mitte der Slide. Ebene wie z-index, ' +
|
||
'leer heißt Standard.</div>' +
|
||
(std.prop
|
||
? '<div class="row" style="margin-top:8px">' +
|
||
'<button class="btn secondary" id="sz-minus">A−</button>' +
|
||
'<button class="btn secondary" id="sz-plus">A+</button></div>'
|
||
: "") +
|
||
'<div class="abschnitt">Ausrichten an der Slide</div>' +
|
||
'<div class="opts">' +
|
||
'<button data-waag="links">Links</button>' +
|
||
'<button data-waag="mitte">Mitte</button>' +
|
||
'<button data-waag="rechts">Rechts</button></div>' +
|
||
'<div class="opts" style="margin-top:4px">' +
|
||
'<button data-senk="oben">Oben</button>' +
|
||
'<button data-senk="mitte">Mitte</button>' +
|
||
'<button data-senk="unten">Unten</button></div>' +
|
||
'<div class="abschnitt">Auf allen Slides</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn secondary" id="align-pos" title="Dieses Element auf allen Slides an dieselbe Stelle">Position angleichen</button>' +
|
||
'<button class="btn secondary" id="align-size" title="Dieses Element auf allen Slides gleich groß">Größe angleichen</button>' +
|
||
'</div>' +
|
||
'<div class="abschnitt">Element</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn secondary" id="reset-one">Zurücksetzen</button>' +
|
||
'<button class="btn secondary" id="f-weg">Löschen</button>' +
|
||
'</div>' +
|
||
verstecktHtml(picked.slide);
|
||
if (wechsel) fitStrip();
|
||
}
|
||
const put = (sel, v) => {
|
||
const el = koerper.querySelector(sel);
|
||
if (el && document.activeElement !== el) el.value = v;
|
||
};
|
||
if (feld) put("#f-text", htmlZuText(SLIDES[picked.slide][feld]));
|
||
if (std.prop) put("#f-size", e.size || base);
|
||
put("#f-z", e.z == null ? "" : e.z);
|
||
const c = centreOf(picked.slide, picked.key);
|
||
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
|
||
}
|
||
|
||
/* Auswahl aufheben, und damit das rechte Feld zu. Zwei Wege, weil einer
|
||
zu wenig war: der Knopf setzt voraus, dass man ihn sieht, und genau das
|
||
war nicht immer der Fall. Escape geht immer.
|
||
|
||
Nicht wenn gerade in einem Feld getippt wird -- dort soll Escape die
|
||
Eingabe verlassen und nicht die Auswahl wegnehmen. */
|
||
function auswahlAufheben() {
|
||
if (!picked) return;
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
}
|
||
document.getElementById("el-zu").addEventListener("click", auswahlAufheben);
|
||
document.addEventListener("keydown", e => {
|
||
if (e.key !== "Escape") return;
|
||
const z = document.activeElement;
|
||
if (z && /^(INPUT|TEXTAREA|SELECT)$/.test(z.tagName)) return;
|
||
auswahlAufheben();
|
||
});
|
||
|
||
document.getElementById("el-inhalt").addEventListener("click", ev => {
|
||
const zurueck = ev.target.closest("[data-zurueck]");
|
||
if (zurueck) {
|
||
const k = zurueck.dataset.zurueck;
|
||
const i = Number(zurueck.dataset.slide);
|
||
const set = (edits[i] || {})[k];
|
||
if (set) delete set.weg;
|
||
applyEditsAll();
|
||
document.getElementById("el-inhalt").dataset.for = "";
|
||
pick(i, k);
|
||
saveState();
|
||
setStatus(((STANDARD[k] || {}).label || k) + " ist wieder da", "ok");
|
||
return;
|
||
}
|
||
const b = ev.target.closest("button");
|
||
if (!b || !picked) return;
|
||
if (b.id === "sz-plus") { resize(1); return; }
|
||
if (b.id === "sz-minus") { resize(-1); return; }
|
||
if (b.id === "reset-one") { resetOne(); return; }
|
||
if (b.id === "align-pos") { alignAll("pos"); return; }
|
||
if (b.id === "align-size") { alignAll("size"); return; }
|
||
if (b.dataset.waag) { ausrichten(picked.slide, picked.key, b.dataset.waag, null); return; }
|
||
if (b.dataset.senk) { ausrichten(picked.slide, picked.key, null, b.dataset.senk); return; }
|
||
if (b.id !== "f-weg") return;
|
||
const i = picked.slide, key = picked.key;
|
||
if (key === "mock-2") {
|
||
// Beim zweiten Bild wirklich entfernen, nicht nur ausblenden: sonst
|
||
// stuende es weiter im Stand und im Auswahlfeld.
|
||
werteVon(i).media2 = null;
|
||
slidesNeuAufbauen();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
show(i);
|
||
syncInhalt();
|
||
} else {
|
||
(edits[i][key] = edits[i][key] || {}).weg = true;
|
||
applyEditsAll();
|
||
// Ausgewaehlt lassen: dann steht "Element zurueck" gleich bereit. Der
|
||
// Aufbau muss aber erzwungen werden, sonst fehlt die Rueckhol-Liste.
|
||
document.getElementById("el-inhalt").dataset.for = "";
|
||
syncEditPanel();
|
||
}
|
||
saveState();
|
||
setStatus("Element entfernt", "ok");
|
||
});
|
||
|
||
/* Eingabe wirkt sofort. Kein pick() danach: das baut das Feld neu auf und
|
||
der Fokus waere weg. */
|
||
document.getElementById("el-inhalt").addEventListener("input", ev => {
|
||
const el = ev.target;
|
||
// textarea zaehlt nicht als input: ohne das faellt das Textfeld durch.
|
||
if (!picked || !el.matches("input, textarea")) return;
|
||
/* Der Text gehoert der Slide, nicht der Bearbeitung: er wandert in
|
||
dieselbe Ueberschreibung, die auch das Inhalt-Feld links beschreibt. */
|
||
if (el.id === "f-text") {
|
||
const feld = TEXT_ZU_FELD[picked.key];
|
||
if (!feld) return;
|
||
werteVon(picked.slide)[feld] = textZuHtml(el.value);
|
||
slidesNeuAufbauen();
|
||
rebuildPage(picked.slide);
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
syncInhalt();
|
||
saveState();
|
||
return;
|
||
}
|
||
const raw = el.value.trim();
|
||
const n = Number(raw);
|
||
if (raw !== "" && !Number.isFinite(n)) return;
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
if (el.id === "f-dx" || el.id === "f-dy") {
|
||
// Der Wert ist eine Stelle auf der Slide, kein Verschiebebetrag. Der
|
||
// Unterschied zur jetzigen Stelle wandert deshalb in den Versatz.
|
||
const c = centreOf(picked.slide, picked.key);
|
||
if (!c) return;
|
||
const ziel = raw === "" ? 0 : Math.round(n);
|
||
if (el.id === "f-dx") set.dx = (set.dx || 0) + (ziel - c.x);
|
||
else set.dy = (set.dy || 0) + (ziel - c.y);
|
||
}
|
||
else if (el.id === "f-z") {
|
||
if (raw === "") delete set.z;
|
||
else set.z = Math.round(n);
|
||
}
|
||
// Leere Groesse heisst Standard, nicht null.
|
||
else if (el.id === "f-size") {
|
||
if (raw === "") delete set.size;
|
||
else set.size = Math.max(8, Math.round(n));
|
||
}
|
||
applyEditsAll();
|
||
saveState();
|
||
});
|
||
/* Welche Kategorien offen sind, ist Bedienvorliebe und gehört nicht in den
|
||
Projektstand: gilt für beide Projekte gleichermaßen. */
|
||
(function rememberGroups() {
|
||
const KEY = "hivegrid.ui.gruppen";
|
||
let open = [];
|
||
try { open = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (_) {}
|
||
document.querySelectorAll("details.group[data-grp]").forEach(d => {
|
||
if (open.includes(d.dataset.grp)) d.open = true;
|
||
d.addEventListener("toggle", () => {
|
||
const now = [...document.querySelectorAll("details.group[open][data-grp]")]
|
||
.map(x => x.dataset.grp);
|
||
try { localStorage.setItem(KEY, JSON.stringify(now)); } catch (_) {}
|
||
});
|
||
});
|
||
})();
|
||
|
||
document.getElementById("forget").addEventListener("click", forgetProject);
|
||
document.getElementById("vorschau").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
setVorschau(b.dataset.p === "an");
|
||
});
|
||
|
||
/* In der Vorschau ist alles festgelegt: keine Auswahl, kein Ziehen, keine
|
||
Rahmen. Blaettern und Zoomen bleiben, das ist der Sinn der Sache. */
|
||
function setVorschau(an) {
|
||
editMode = an ? "aus" : "an";
|
||
press("vorschau", "p", an ? "an" : "aus");
|
||
deck.querySelectorAll(".slide").forEach(el => { el.dataset.editmode = editMode; });
|
||
if (an) {
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
}
|
||
}
|
||
|
||
/* ─── Angleichen über alle Slides ────────────────────────────────────────
|
||
Das gewählte Element ist die Vorlage, alle gleichnamigen auf den anderen
|
||
Slides ziehen nach. Angeglichen wird die Stelle, nicht der Versatz: jedes
|
||
Element liegt von sich aus woanders, und nur die Stelle ist vergleichbar.
|
||
In der Einzelansicht haben die übrigen Holder kein Layout, weil sie
|
||
ausgeblendet sind. Jeder wird deshalb kurz sichtbar gemacht — derselbe
|
||
Weg, den auch der Export geht. */
|
||
function alignAll(was) {
|
||
if (!picked) return;
|
||
const key = picked.key;
|
||
const ziel = was === "size" ? null : centreOf(picked.slide, key);
|
||
if (was !== "size" && !ziel) { setStatus("Element nicht messbar", "err"); return; }
|
||
const size = (edits[picked.slide][key] || {}).size;
|
||
const holders = deck.querySelectorAll(".holder");
|
||
let n = 0;
|
||
SLIDES.forEach((_, i) => {
|
||
if (i === picked.slide) return;
|
||
const holder = holders[i];
|
||
if (!holder || !holder.querySelector('.page[data-j="' + i + '"] [data-edit="' + key + '"]')) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
if (was === "size") {
|
||
if (size == null) delete set.size; else set.size = size;
|
||
} else {
|
||
const c = centreOf(i, key);
|
||
if (c) {
|
||
set.dx = (set.dx || 0) + (ziel.x - c.x);
|
||
set.dy = (set.dy || 0) + (ziel.y - c.y);
|
||
}
|
||
}
|
||
holder.style.display = restore;
|
||
n++;
|
||
});
|
||
applyEditsAll();
|
||
syncEditPanel();
|
||
saveState();
|
||
const label = (STANDARD[key] || {}).label || key;
|
||
setStatus(label + ": " + (was === "size" ? "Größe" : "Position") +
|
||
" auf " + n + " weitere Slide" + (n === 1 ? "" : "s") + " übertragen", "ok");
|
||
}
|
||
|
||
|
||
/* ─── Merkzettel ─────────────────────────────────────────────────────────
|
||
Der gemerkte Stand liegt als eigenes Projekt in state/, also im Repo und
|
||
damit auch auf der anderen Maschine. Zurueckgeholt wird er, indem er zum
|
||
laufenden Stand gemacht und die Seite neu geladen wird: dann greift
|
||
derselbe Ladepfad wie sonst auch, statt dass ich hier von Hand jede
|
||
Einstellung nachziehe und dabei eine vergesse. */
|
||
const SNAP_PROJECT = PROJECT + "-merk";
|
||
const snapSave = document.getElementById("snap-save");
|
||
const snapLoad = document.getElementById("snap-load");
|
||
|
||
// Was aus der Merkdatei kommt, wird geprueft, bevor es den laufenden Stand
|
||
// ersetzt: Slide-Anzahl und Version muessen zu diesem Blatt passen.
|
||
function snapUsable(d) {
|
||
return !!d && typeof d.v === "number" && d.v >= 1 && d.v <= STATE_V
|
||
&& Array.isArray(d.edits) && d.edits.length === SLIDES.length
|
||
&& Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length
|
||
&& Array.isArray(d.bgState) && d.bgState.length === SLIDES.length;
|
||
}
|
||
|
||
// Immer frisch: der Merkzettel wird waehrend der Arbeit ueberschrieben, ein
|
||
// gecachter Treffer wuerde den Stand von vorhin zurueckholen.
|
||
function snapUrl() {
|
||
return "./state/" + SNAP_PROJECT + ".json?t=" + Date.now();
|
||
}
|
||
|
||
async function readSnap() {
|
||
const res = await fetch(snapUrl(), { cache: "no-store" });
|
||
if (!res.ok) throw new Error("kein gemerkter Stand");
|
||
return await res.json();
|
||
}
|
||
|
||
async function snapExists() {
|
||
try { return snapUsable(await readSnap()); }
|
||
catch (_) { return false; }
|
||
}
|
||
snapExists().then(ok => { snapLoad.disabled = !ok; });
|
||
|
||
snapSave.addEventListener("click", async () => {
|
||
snapSave.disabled = true;
|
||
try {
|
||
const mine = currentState();
|
||
const res = await fetch("/state?project=" + SNAP_PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(mine),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
// Gegengelesen: gemerkt ist erst, was auch wirklich in der Datei steht.
|
||
// Ein "ok" vom Server allein hat hier schon einmal zu wenig bewiesen.
|
||
const back = await readSnap();
|
||
if (!snapUsable(back)) throw new Error("Datei unbrauchbar zurueckgelesen");
|
||
if (JSON.stringify(back.edits) !== JSON.stringify(mine.edits)
|
||
|| JSON.stringify(back.bgState) !== JSON.stringify(mine.bgState)
|
||
|| JSON.stringify(back.layoutState) !== JSON.stringify(mine.layoutState)) {
|
||
throw new Error("Datei weicht vom Stand ab");
|
||
}
|
||
snapLoad.disabled = false;
|
||
setStatus("Stand gemerkt und gegengelesen", "ok");
|
||
} catch (err) {
|
||
snapLoad.disabled = !(await snapExists());
|
||
setStatus("Merken fehlgeschlagen: " + err.message, "err");
|
||
} finally { snapSave.disabled = false; }
|
||
});
|
||
|
||
snapLoad.addEventListener("click", async () => {
|
||
snapLoad.disabled = true;
|
||
try {
|
||
const d = await readSnap();
|
||
// Erst pruefen, dann ersetzen. Ein Merkzettel, der nicht passt, darf
|
||
// nicht den Stand kosten, der gerade auf dem Schirm steht.
|
||
if (!snapUsable(d)) throw new Error("gemerkter Stand passt nicht zu diesem Blatt");
|
||
// Rueckfahrkarte: der Stand von jetzt wird als <projekt>-vorher abgelegt,
|
||
// bevor er ueberschrieben wird. Ein Fehlklick ist damit umkehrbar.
|
||
await fetch("/state?project=" + PROJECT + "-vorher", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(currentState()),
|
||
}).catch(() => {});
|
||
// Den laufenden Timer entschaerfen und dichtmachen, sonst schreibt der
|
||
// Reload den alten Stand ueber den gerade zurueckgeholten.
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
stateLocked = true;
|
||
const put = await fetch("/state?project=" + PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(d),
|
||
});
|
||
if (!put.ok) throw new Error("HTTP " + put.status);
|
||
// Gegenlesen, bevor neu geladen wird: erst wenn die Datei steht, ist der
|
||
// Reload sicher. Sonst startet das Blatt ohne Stand.
|
||
const back = await fetch(STATE_URL + "?t=" + Date.now(), { cache: "no-store" });
|
||
if (!back.ok || !snapUsable(await back.json())) throw new Error("Stand nicht geschrieben");
|
||
try { localStorage.setItem(STORE, JSON.stringify(d)); } catch (_) {}
|
||
location.reload();
|
||
} catch (err) {
|
||
stateLocked = false;
|
||
snapLoad.disabled = false;
|
||
setStatus("Zurueckholen fehlgeschlagen: " + err.message, "err");
|
||
}
|
||
});
|
||
|
||
/* ─── Menueleiste ────────────────────────────────────────────────────────
|
||
Die Eintraege fuehren nichts selbst aus, sondern betaetigen die Schalter
|
||
im Bedienfeld. Damit gibt es weiter genau eine Stelle, an der ein
|
||
Verhalten steht, und die Leiste kann nicht auseinanderlaufen.
|
||
data-klick Knopf mit dieser id anklicken
|
||
data-opt Knopf einer Optionsgruppe: gruppe:attribut:wert
|
||
data-tun benannte Aktion
|
||
data-gehe anderes Projekt oeffnen */
|
||
(function menueleiste() {
|
||
const bar = document.getElementById("menubar");
|
||
if (!bar) return;
|
||
const menues = [...bar.querySelectorAll(".menu")];
|
||
const zu = () => menues.forEach(m => delete m.dataset.offen);
|
||
|
||
const AKTION = { undo, redo, vorschau: () => setVorschau(editMode === "an") };
|
||
|
||
// Haken und graue Eintraege erst beim Oeffnen setzen: vorher weiss die
|
||
// Leiste nicht, was gerade gilt.
|
||
function stand(m) {
|
||
m.querySelectorAll("button[data-opt]").forEach(b => {
|
||
const [gruppe, attr, wert] = b.dataset.opt.split(":");
|
||
const q = document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]');
|
||
const an = q && q.getAttribute("aria-pressed") === "true";
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (an ? "✓" : "") + "</span>");
|
||
});
|
||
m.querySelectorAll('button[data-tun="vorschau"]').forEach(b => {
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (editMode === "aus" ? "✓" : "") + "</span>");
|
||
});
|
||
m.querySelectorAll("button[data-klick]").forEach(b => {
|
||
const q = document.getElementById(b.dataset.klick);
|
||
b.disabled = !q || q.disabled;
|
||
});
|
||
m.querySelectorAll("button[data-gehe]").forEach(b => {
|
||
const hier = b.dataset.gehe.endsWith(location.pathname.split("/").pop() || "post1.html");
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (hier ? "✓" : "") + "</span>");
|
||
});
|
||
}
|
||
|
||
bar.addEventListener("click", e => {
|
||
const titel = e.target.closest(".menu > button");
|
||
if (titel) {
|
||
const m = titel.parentElement;
|
||
const war = m.dataset.offen === "ja";
|
||
zu();
|
||
if (!war) { m.dataset.offen = "ja"; stand(m); }
|
||
return;
|
||
}
|
||
const el = e.target.closest(".liste button");
|
||
if (!el || el.disabled) return;
|
||
zu();
|
||
if (el.dataset.gehe) { location.href = el.dataset.gehe; return; }
|
||
if (el.dataset.tun) { (AKTION[el.dataset.tun] || (() => {}))(); return; }
|
||
if (el.dataset.klick) { document.getElementById(el.dataset.klick)?.click(); return; }
|
||
if (el.dataset.opt) {
|
||
const [gruppe, attr, wert] = el.dataset.opt.split(":");
|
||
document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]')?.click();
|
||
}
|
||
});
|
||
|
||
// Ueberfahren wechselt das offene Menue, wie gewohnt.
|
||
bar.addEventListener("mouseover", e => {
|
||
const titel = e.target.closest(".menu > button");
|
||
if (!titel || !menues.some(m => m.dataset.offen === "ja")) return;
|
||
const m = titel.parentElement;
|
||
if (m.dataset.offen === "ja") return;
|
||
zu(); m.dataset.offen = "ja"; stand(m);
|
||
});
|
||
|
||
document.addEventListener("mousedown", e => { if (!bar.contains(e.target)) zu(); });
|
||
document.addEventListener("keydown", e => { if (e.key === "Escape") zu(); });
|
||
})();
|
||
|
||
document.getElementById("reset-all").addEventListener("click", resetAll);
|
||
|
||
// Übersicht der Standards, direkt aus derselben Tabelle wie der Reset.
|
||
(function renderStandards() {
|
||
const groups = [
|
||
["Schrift", ["badge", "headline", "body", "wabe-tagline", "wabe-claim", "title-head", "title-sub"]],
|
||
["Breite", ["wabe-logo", "logo-full", "screen-logo", "laptop", "szene"]],
|
||
];
|
||
let html = "";
|
||
for (const [title, keys] of groups) {
|
||
html += "<h4>" + title + "</h4><table>";
|
||
for (const k of keys) {
|
||
const st = STANDARD[k];
|
||
html += "<tr><td>" + st.label + "</td><td>" + st.size + " px</td></tr>";
|
||
}
|
||
html += "</table>";
|
||
}
|
||
html += '<h4>Fest</h4><table>' +
|
||
'<tr><td>Slide</td><td>1080 × 1350</td></tr>' +
|
||
'<tr><td>Innenabstand</td><td>92 px</td></tr>' +
|
||
'<tr><td>Tag ausgerückt</td><td>−20 px</td></tr>' +
|
||
'<tr><td>Mockup</td><td>1090 px · 16:10</td></tr>' +
|
||
'</table>';
|
||
document.getElementById("std-list").innerHTML = html;
|
||
})();
|
||
|
||
document.getElementById("views").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { view = b.dataset.w; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); saveState(); }
|
||
});
|
||
window.addEventListener("resize", fitStrip);
|
||
|
||
/* deltaMode: 0 = Pixel, 1 = Zeilen (Firefox), 2 = Seiten. Ohne Umrechnung
|
||
zoomt Firefox pro Rastung um ein Vielfaches weniger als Chrome. */
|
||
function wheelPx(v, mode) { return v * (mode === 1 ? 16 : mode === 2 ? 400 : 1); }
|
||
|
||
/* Blankes Rad zoomt — eine gewöhnliche PC-Maus soll ohne Zusatztaste
|
||
auskommen. Strg/Cmd + Rad tut dasselbe, damit der Trackpad-Pinch (der
|
||
ctrlKey mitschickt) weiter greift. Waagerechtes Wischen und ⇧ + Rad
|
||
schieben dagegen den Ausschnitt: ein Trackpad darf sich nicht wie eine
|
||
Zoomwippe anfühlen. */
|
||
document.getElementById("scroller").addEventListener("wheel", e => {
|
||
const sc = e.currentTarget;
|
||
const dx = wheelPx(e.deltaX, e.deltaMode), dy = wheelPx(e.deltaY, e.deltaMode);
|
||
if (e.shiftKey || (!dy && dx)) {
|
||
e.preventDefault();
|
||
sc.scrollLeft += (dx || dy);
|
||
return;
|
||
}
|
||
if (!dy) return;
|
||
e.preventDefault();
|
||
const r = sc.getBoundingClientRect();
|
||
// Exponentiell gerechnet fühlen sich Rastung (~100 px auf einen Schlag)
|
||
// und Trackpad (viele kleine Werte) gleich an. Der Deckel hält die
|
||
// Schwung-Schübe im Zaum, die macOS nach dem Loslassen nachschiebt.
|
||
const k = Math.min(3, Math.max(1 / 3, Math.exp(-dy * 0.0015)));
|
||
setZoom((zoom || autoFit()) * k,
|
||
{ x: e.clientX - r.left, y: e.clientY - r.top });
|
||
}, { passive: false });
|
||
|
||
/* Panning: linke Maustaste auf der Fläche reicht. Leertaste und mittlere
|
||
Taste bleiben als gewohnte Wege bestehen — im Bearbeiten-Modus sind sie
|
||
der einzige, denn dort gehört der linke Zug dem Element unter dem Zeiger. */
|
||
(function panning() {
|
||
const sc = document.getElementById("scroller");
|
||
let space = false, active = null, dragged = false;
|
||
|
||
document.addEventListener("keydown", e => {
|
||
if (e.code !== "Space" || e.target.matches("input, textarea")) return;
|
||
e.preventDefault();
|
||
if (!space) { space = true; sc.classList.add("panready"); }
|
||
});
|
||
document.addEventListener("keyup", e => {
|
||
if (e.code !== "Space") return;
|
||
space = false; sc.classList.remove("panready");
|
||
});
|
||
|
||
sc.addEventListener("mousedown", e => {
|
||
dragged = false;
|
||
if (!space && e.button !== 1) {
|
||
// Linke Taste zieht die Fläche, außer sie hat im Bearbeiten-Modus
|
||
// ein Element erwischt: das verschiebt sich selbst.
|
||
if (e.button !== 0) return;
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
}
|
||
e.preventDefault();
|
||
active = { x: e.clientX, y: e.clientY, l: sc.scrollLeft, t: sc.scrollTop };
|
||
sc.classList.add("panning");
|
||
});
|
||
document.addEventListener("mousemove", e => {
|
||
if (!active) return;
|
||
const dx = e.clientX - active.x, dy = e.clientY - active.y;
|
||
// Erst ab ein paar Pixeln ist es ein Zug und kein Klick, sonst
|
||
// verschluckt jede zittrige Hand die Slide-Auswahl.
|
||
if (Math.abs(dx) + Math.abs(dy) > 3) dragged = true;
|
||
sc.scrollLeft = active.l - dx;
|
||
sc.scrollTop = active.t - dy;
|
||
});
|
||
document.addEventListener("mouseup", () => {
|
||
active = null; sc.classList.remove("panning");
|
||
});
|
||
// Nach echtem Ziehen den Klick schlucken: sonst springt die Auswahl auf
|
||
// die Slide, über der die Maus zufällig loslässt.
|
||
sc.addEventListener("click", e => {
|
||
if (!dragged) return;
|
||
dragged = false;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}, true);
|
||
})();
|
||
|
||
document.getElementById("zoom-in").addEventListener("click", () => setZoom((zoom || autoFit()) * 1.25));
|
||
document.getElementById("zoom-out").addEventListener("click", () => setZoom((zoom || autoFit()) / 1.25));
|
||
document.getElementById("zoom-fit").addEventListener("click", () => { zoom = null; fitStrip(); });
|
||
|
||
nav.addEventListener("click", e => {
|
||
const b = e.target.closest("button"); if (b) show(Number(b.dataset.n));
|
||
});
|
||
document.getElementById("variants").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { variant = b.dataset.v; press("variants", "v", variant); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("layouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { layoutState[current] = b.dataset.l; syncLayoutButtons(); rebuildPage(current); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("ints").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("tagouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { tagOut = b.dataset.o; press("tagouts", "o", tagOut); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("frames").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("tilts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { tilt = b.dataset.t; press("tilts", "t", tilt); applyAll(); saveState(); }
|
||
});
|
||
|
||
document.getElementById("quality").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { superSample = Number(b.dataset.q); press("quality", "q", b.dataset.q); saveState(); }
|
||
});
|
||
|
||
document.getElementById("dl-png").addEventListener("click", guard(downloadOne));
|
||
document.getElementById("dl-all").addEventListener("click", guard(downloadAll));
|
||
|
||
/* ⌘/Strg + Z. Eigener Zuhoerer, weil der allgemeine unten alles mit
|
||
Sondertaste durchlaesst. In Eingabefeldern hat das Zurueckgehen des
|
||
Browsers Vorrang, sonst kaeme man aus einem Tippfehler nicht mehr heraus. */
|
||
document.addEventListener("keydown", e => {
|
||
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k !== "z" && k !== "y") return;
|
||
const t = e.target;
|
||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||
e.preventDefault();
|
||
if (k === "y" || e.shiftKey) redo(); else undo();
|
||
});
|
||
|
||
document.addEventListener("keydown", e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
// In einem Eingabefeld gehoeren die Tasten dem Feld, nicht den Kuerzeln.
|
||
// Ist nichts fokussiert, ist das Ziel das Dokument selbst — das kennt
|
||
// kein matches. Seit Slides hinzukommen und wegfallen koennen, ist genau
|
||
// das erreichbar: der Neuaufbau entfernt den zuletzt fokussierten Knopf.
|
||
if (e.target && e.target.matches && e.target.matches("input, textarea, select")) return;
|
||
const k = e.key.toLowerCase();
|
||
if (editMode === "an" && picked && e.key.startsWith("Arrow")) {
|
||
const step = e.shiftKey ? 10 : 1;
|
||
if (e.key === "ArrowRight") nudge(step, 0);
|
||
else if (e.key === "ArrowLeft") nudge(-step, 0);
|
||
else if (e.key === "ArrowDown") nudge(0, step);
|
||
else if (e.key === "ArrowUp") nudge(0, -step);
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (e.key === "ArrowRight") show(current + 1);
|
||
else if (e.key === "ArrowLeft") show(current - 1);
|
||
else if (k === "v") { variant = variant === "dunkel" ? "hell" : "dunkel"; press("variants", "v", variant); applyAll(); saveState(); }
|
||
else if (k === "l") {
|
||
const cat = SLIDES[current].layouts;
|
||
const n = cat.findIndex(x => x.id === layoutState[current]);
|
||
layoutState[current] = cat[(n + 1) % cat.length].id;
|
||
syncLayoutButtons(); rebuildPage(current); applyAll(); saveState();
|
||
}
|
||
else if (k === "i") {
|
||
const st = bgState[current];
|
||
st.int = st.int === "dezent" ? "kraeftig" : "dezent";
|
||
syncBgButtons(); applyAll();
|
||
}
|
||
else if (k === "t") { tilt = tilt === "an" ? "aus" : "an"; press("tilts", "t", tilt); applyAll(); saveState(); }
|
||
else if (e.key === "+" || e.key === "=") { setZoom((zoom || autoFit()) * 1.25); }
|
||
else if (e.key === "-") { setZoom((zoom || autoFit()) / 1.25); }
|
||
else if (e.key === "0") { zoom = null; fitStrip(); }
|
||
else if (k === "w") { view = view === "streifen" ? "einzeln" : "streifen"; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); }
|
||
else if (k === "p") { setVorschau(editMode === "an"); }
|
||
else if (k === "r") {
|
||
const cyc = ["chamfer", "rund", "stapel", "aus"];
|
||
frameStyle = cyc[(cyc.indexOf(frameStyle) + 1) % cyc.length];
|
||
press("frames", "f", frameStyle); applyAll(); saveState();
|
||
}
|
||
else if (k === "d") guard(downloadOne)();
|
||
else if (k === "a") guard(downloadAll)();
|
||
});
|
||
|
||
/* Boot: inline the screens, wait for Manrope to actually be resident
|
||
(otherwise the first export silently falls back to a system font), and
|
||
pre-compute the font CSS once instead of on every render. */
|
||
(async function boot() {
|
||
try {
|
||
setStatus("Lade Screens ...");
|
||
await bilderNachladen();
|
||
await preloadAssets();
|
||
const restored = await loadState();
|
||
/* Verweise auf Bilder, die es nicht mehr gibt, hier aufloesen statt sie
|
||
bis in den Export zu schleppen. Sie entstehen, wenn eine importierte
|
||
Datei geloescht oder nicht mitgepusht wurde. */
|
||
const verloren = [];
|
||
slideListe.forEach((e, i) => {
|
||
const w = e.werte || {};
|
||
if (w.media2 && w.media2.img && !assets[w.media2.img]) {
|
||
verloren.push(w.media2.img.split("/").pop());
|
||
w.media2 = null;
|
||
}
|
||
if (w.img && !assets[w.img]) { verloren.push(w.img.split("/").pop()); delete w.img; }
|
||
});
|
||
if (verloren.length) {
|
||
slidesNeuAufbauen();
|
||
saveState();
|
||
}
|
||
buildDeck();
|
||
deck.dataset.view = view;
|
||
press("variants", "v", variant);
|
||
press("tilts", "t", tilt);
|
||
press("quality", "q", String(superSample));
|
||
press("tagouts", "o", tagOut);
|
||
press("frames", "f", frameStyle);
|
||
press("views", "w", view);
|
||
anzahlNachziehen();
|
||
show(current);
|
||
animLeisteBauen();
|
||
if (verloren.length)
|
||
setStatus("Fehlende Bilder entfernt: " + verloren.join(", ") +
|
||
" · bitte neu importieren", "err");
|
||
else if (restored) setStatus("Projektstand geladen", "ok");
|
||
await document.fonts.ready;
|
||
fontEmbedCss = await htmlToImage.getFontEmbedCSS(deck.querySelector(".slide"));
|
||
["dl-png", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
|
||
if (checkCollisions()) setStatus("Bereit. " + SLIDES.length +
|
||
(SLIDES.length === 1 ? " Slide" : " Slides") + ", 1080 × 1350 px", "ok");
|
||
animNurModus();
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus("Fehler beim Laden: " + err.message, "err");
|
||
}
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|