Auf allem stand no-store -- im Server und noch einmal in preloadAssets(). Der Browser durfte also nichts behalten, und jeder Reload lud die vollen Assets neu: 3 MB Screenshots, 3,3 MB Mockups, Schriften, Bibliotheken. Am schlimmsten in uebersicht.html. Die laedt die sechs Projekte als iframes, und weil keiner etwas behalten durfte, holte sich jeder iframe dieselben Dateien noch einmal: 38,1 MB in 435 Anfragen. uebersicht.html erster Aufruf 38,1 MB -> 2,2 MB uebersicht.html Reload 32,8 MB -> 1,9 MB post6.html Reload 5,4 MB -> 0,3 MB Die Absicht hinter no-store war richtig: ein Bild, das unter demselben Namen ausgetauscht wird, muss sofort ankommen. no-cache leistet das billiger -- der Browser behaelt seine Kopie, fragt aber vor jeder Benutzung nach. Nachgeprueft: unveraendert antwortet der Server mit 304 und 0 Bytes, nach einem touch mit 200 und der vollen Datei. Seite, Stand und Auskuenfte (/bilder, /state) bleiben bei no-store. Die sind klein und duerfen nie aus einer Kopie kommen. Die eine Zeile stand in allen sieben Projektdateien. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7RZ6ZpLD8Dp84Rtt4KHfv
5673 lines
264 KiB
HTML
5673 lines
264 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 · Alternative</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. */
|
||
|
||
/* Das Raster gehoert zum Theme notebook. Hier bleibt der Grund normalerweise
|
||
ruhig; eine Textur nimmt den schwebenden Karten den Halt. Die Ebene bleibt
|
||
im Markup, damit beide Themes denselben Aufbau haben.
|
||
|
||
Zum Ausprobieren laesst es sich unter "Raster" wieder einschalten --
|
||
dieselben Werte wie in post1.html: 78 px Teilung, 2 px Strich. */
|
||
.layer-raster { background: none; }
|
||
.slide[data-raster="an"] .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(--int-mul, 1) * .17);
|
||
}
|
||
.slide[data-raster="an"][data-variant="dunkel"] .layer-raster {
|
||
opacity: calc(var(--int-mul, 1) * .26);
|
||
}
|
||
|
||
/* Ueberschrift und Fliesstext mittig. Der Fliesstext hat eine max-width,
|
||
also braucht er zusaetzlich automatische Seitenraender -- text-align
|
||
allein zentriert nur die Zeilen im Kasten, nicht den Kasten. */
|
||
.slide[data-mitte="an"] .content { text-align: center; }
|
||
.slide[data-mitte="an"] .body { margin-left: auto; margin-right: auto; }
|
||
|
||
/* ─── Das Band ──────────────────────────────────────────────────────────
|
||
Ein durchgehendes Band ueber den ganzen Streifen, jede Slide zeigt nur
|
||
ihren Ausschnitt. Die Verschiebung laeuft ueber --slide-i, das die
|
||
Slide ohnehin schon traegt: Slide 3 zeigt also die dritten 1080 px
|
||
derselben Zeichnung. An der Naht laeuft die Linie damit weiter, statt
|
||
neu anzusetzen -- das ist der Uebergang, den ein Carousel sonst nicht
|
||
hat, weil jede Slide fuer sich steht.
|
||
|
||
Es liegt in .bg und damit unter allem. Deckend heisst aber auch:
|
||
es deckt. Was darueber liegen soll, muss selbst deckend sein --
|
||
Linienwerk auf Transparenz verschwindet darin. */
|
||
.layer-band {
|
||
position: absolute; top: 0;
|
||
left: calc(var(--slide-i) * -1080px);
|
||
}
|
||
|
||
|
||
/* ─── 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; overflow: visible; }
|
||
|
||
/* 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%;
|
||
}
|
||
/* Kein gezeichneter Rahmen mehr: das Foto ist selbst chamfert, das ist die
|
||
Markenform, und ein dunkles Bild auf hellem Grund braucht keine
|
||
Umrandung. Die Zeichenebene bleibt im Markup, zeichnet aber nichts. */
|
||
.vis svg.karte-skizze { display: none; }
|
||
/* Der Rahmen der Ueber-uns-Seite, Werte aus TeamCards.tsx: die Karte
|
||
traegt aussen Radius 38 auf teal-hellem Grund, das abgedunkelte Foto
|
||
sitzt 4,2 % eingerueckt mit Radius 27 (= 38 x .72). Die Person darf
|
||
ueber diesen Rand hinausragen und wird erst vom Aussenradius
|
||
beschnitten -- daher inset 0 und z-index 4. */
|
||
.karte {
|
||
position: relative; aspect-ratio: 1/1; overflow: hidden;
|
||
background: var(--accent-teal); isolation: isolate; border-radius: 38px;
|
||
}
|
||
.karte-grund {
|
||
position: absolute; inset: 4.2%; z-index: 1; overflow: hidden;
|
||
border-radius: 27px; background: #2b2d2a;
|
||
filter: grayscale(.04) saturate(.74) brightness(.73);
|
||
}
|
||
.karte-grund img { position: absolute; inset: 0; width: 100%; height: 100%;
|
||
object-fit: cover; object-position: 50% 34%; }
|
||
.karte-wash {
|
||
position: absolute; inset: 4.2%; border-radius: 27px;
|
||
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; object-position: 50% 34%;
|
||
filter: saturate(1) contrast(1.01);
|
||
}
|
||
/* Die Infokarte schwebt ueber dem Foto, statt als flaches Schild darin zu
|
||
kleben. Weiss auf dunklem Bild, chamfert und ohne Rand: clip-path wuerde
|
||
ihn beschneiden (lib/chamfer.ts der Website). */
|
||
.karte-name {
|
||
position: absolute; left: 5.4%; bottom: 5.4%; z-index: 5;
|
||
display: flex; align-items: center; gap: 18px;
|
||
padding: 20px 28px 20px 20px; background: #FFFFFF;
|
||
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
|
||
);
|
||
}
|
||
/* Die G-Marke statt einer nackten Wabe. Sie hat fast dieselben
|
||
Proportionen (384,5 : 332,9), passt also in denselben Platz. Als
|
||
Inline-SVG und nicht als Datei: ein Bild im foreignObject kann beim
|
||
Export verschwinden (siehe 7). */
|
||
.karte-name .marke {
|
||
width: 52px; height: 45px; flex: none; display: block;
|
||
}
|
||
.karte-name .marke svg { display: block; width: 100%; height: 100%; }
|
||
.karte-name .zeilen { display: flex; flex-direction: column; gap: 2px; }
|
||
.karte-name b { font-size: 30px; font-weight: 800; letter-spacing: -.025em;
|
||
line-height: 1.1; color: #1A1A1A; }
|
||
.karte-name small { font-size: 20px; font-weight: 600; color: #555555; }
|
||
|
||
/* Das Badge der Landingpage, Werte aus LandingPageClient.tsx: Monospace,
|
||
um 10 Grad gedreht, rechts ueber die Kante haengend. Masse mit 1,9
|
||
skaliert -- die Website zeigt es auf einem 680er Foto, hier steht es auf
|
||
einem 1080er Blatt, das im Feed auf gut 400 px schrumpft. Der
|
||
drop-shadow der Vorlage faellt weg (§5).
|
||
|
||
Es liegt neben .karte und nicht darin: .karte hat overflow:hidden fuer
|
||
den Radius, und ein Badge, das ueber die Kante haengt, waere dort
|
||
abgeschnitten. */
|
||
.karte-chip {
|
||
position: absolute; right: -50px; top: 104px; z-index: 7;
|
||
padding: 24px 32px; background: var(--accent-yellow); color: #3F2F0A;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 25px; font-weight: 850; letter-spacing: .1em;
|
||
text-transform: uppercase; white-space: nowrap;
|
||
transform: rotate(10deg); transform-origin: right center;
|
||
clip-path: polygon(
|
||
19px 0, calc(100% - 19px) 0, 100% 19px, 100% calc(100% - 19px),
|
||
calc(100% - 19px) 100%, 19px 100%, 0 calc(100% - 19px), 0 19px
|
||
);
|
||
}
|
||
|
||
/* 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; }
|
||
|
||
/* Im Theme modern traegt das Badge wieder Platz, deshalb beginnt der Block
|
||
bei 92 -- dem Rand der Safe Zone -- statt bei den 59 des Themes notebook. */
|
||
.content { position: relative; z-index: 2; padding: 92px 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. */
|
||
/* Das Chamfer-Badge der Website: gelb aufgehellte Flaeche, dunkler Text,
|
||
Versalien und weite Sperrung. Es steht dort ueber jeder Sektion. Im Theme
|
||
notebook ist es ausgeblendet, hier traegt es die Einordnung. */
|
||
/* Ausgeblendet. Das Badge stand testweise ueber jeder Slide, wie auf der
|
||
Website ueber jeder Sektion. Auf einer Slide nimmt es aber Platz weg,
|
||
ohne etwas zu sagen, was die Ueberschrift nicht schon sagt. */
|
||
.badge {
|
||
display: none; margin-bottom: 28px;
|
||
font-size: 24px; font-weight: 600; letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
padding: 11px 22px;
|
||
background: var(--accent-yellow-light);
|
||
color: var(--accent-yellow-dark);
|
||
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. */
|
||
/* Groesser und enger als im Theme notebook: die Vorbilder setzen die
|
||
Ueberschrift als Blickfang ueber fast die ganze Breite. */
|
||
.headline {
|
||
font-size: 76px; font-weight: 800;
|
||
letter-spacing: -0.032em; line-height: 1.04;
|
||
color: var(--head-color);
|
||
}
|
||
/* Kein Marker. Der Akzent ist im Theme modern ein farbiges Wort mit einem
|
||
Balken darunter -- der Marker ist das Erkennungszeichen des Themes
|
||
notebook und hat hier nichts verloren.
|
||
|
||
Der Balken ist der Hover-Zustand der Ueberschrift auf der Landingpage
|
||
(LandingPageClient.tsx: h-[3px], -bottom-1, bg-current, im Ruhezustand
|
||
auf 22 % gestaucht, bei Hover auf volle Breite). Wir nehmen die volle
|
||
Breite, ein Standbild kennt kein Hover.
|
||
|
||
Masse in em statt in px: dort steht der Balken unter 62 px Schrift,
|
||
hier unter 76 -- und auf der Titel-Slide unter 88. 3/62 und 4/62
|
||
ergeben die Werte unten und wachsen damit von selbst mit. */
|
||
.headline .a, .title-head .a, .wabe-tagline .a {
|
||
color: var(--accent-yellow);
|
||
background: none;
|
||
padding: 0; margin: 0;
|
||
position: relative;
|
||
}
|
||
.headline .a::after, .title-head .a::after, .wabe-tagline .a::after {
|
||
content: ""; position: absolute; left: 0; right: 0;
|
||
bottom: -0.065em; height: 0.048em;
|
||
background: currentColor;
|
||
}
|
||
|
||
.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 ────────────────────────────────────────────────── */
|
||
/* Theme modern, dunkle Variante. bg-dark aus der Website. */
|
||
.slide[data-variant="dunkel"] {
|
||
background: #0F1A2E;
|
||
--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: 0;
|
||
--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);
|
||
}
|
||
|
||
/* ─── Variant: hell ──────────────────────────────────────────────────── */
|
||
/* Theme modern, helle Variante. Die Werte stammen aus app/globals.css der
|
||
Website, nicht aus eigener Erfindung: surface, text-primary,
|
||
text-secondary, container-bg, border-neutral. Eine erfundene Palette war
|
||
der erste Fehler dieser Fassung. */
|
||
.slide[data-variant="hell"] {
|
||
background: var(--surface);
|
||
--head-color: #1A1A1A;
|
||
--body-color: #555555;
|
||
--accent-on: #C8901A;
|
||
--marker: var(--accent-yellow);
|
||
--frame-bg: #F6F8FA;
|
||
--shape-fill: #1A1A1A;
|
||
--shape-line: #E5E5E5;
|
||
--pat-color: #30313D; --pat-strength: 0;
|
||
--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>
|
||
<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 · Alternative</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="versuch">
|
||
<summary>Versuch</summary>
|
||
<div class="opts" id="rasters">
|
||
<button data-r="aus" aria-pressed="true">Raster aus</button>
|
||
<button data-r="an" aria-pressed="false">Raster an</button>
|
||
</div>
|
||
<div class="opts" id="mitten">
|
||
<button data-m="aus" aria-pressed="true">Text links</button>
|
||
<button data-m="an" aria-pressed="false">Text mittig</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details class="group" data-grp="band">
|
||
<summary>Band</summary>
|
||
<div class="opts" id="bands">
|
||
<button data-b="orange" aria-pressed="true">Orange</button>
|
||
<button data-b="teal" aria-pressed="false">HiveGrid Teal</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: "standard", label: "Standard" }],
|
||
eyebrow: "Die Geschichte",
|
||
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: 896, visLeft: 92, visBottom: 96,
|
||
},
|
||
{
|
||
key: "prozesse", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Der Alltag",
|
||
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: "Der Wechsel",
|
||
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: 130,
|
||
},
|
||
{
|
||
key: "mehr", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Was gefehlt hat",
|
||
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: "Die Lösung",
|
||
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: "So geht's weiter",
|
||
/* 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: 240,
|
||
},
|
||
];
|
||
|
||
/* 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 = () => 0; // keine Handschrift in dieser Fassung
|
||
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. */
|
||
/* Gerade Linie. In der Skizzenfassung schwingt sie und schiesst an den
|
||
Enden ueber; hier nicht. Die Aufrufe bleiben identisch, damit beide
|
||
Fassungen dieselben Kompositionen benutzen koennen. */
|
||
function strich(x1, y1, x2, y2) {
|
||
return "M" + n1(x1) + "," + n1(y1) + "L" + n1(x2) + "," + n1(y2);
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* Exakte Ellipse. Kein offener Zug, kein Ueberschuss. */
|
||
function kringel(cx, cy, rx, ry) {
|
||
ry = ry == null ? rx : ry;
|
||
return "M" + n1(cx - rx) + "," + n1(cy) +
|
||
"a" + n1(rx) + "," + n1(ry) + " 0 1 0 " + n1(rx * 2) + ",0" +
|
||
"a" + n1(rx) + "," + n1(ry) + " 0 1 0 " + n1(-rx * 2) + ",0Z";
|
||
}
|
||
|
||
/* Exaktes Rechteck. */
|
||
function rechteck(x, y, w, h) {
|
||
return "M" + n1(x) + "," + n1(y) + "h" + n1(w) + "v" + n1(h) + "h" + n1(-w) + "Z";
|
||
}
|
||
|
||
/* Der Chamfer, exakt. Die Markenform aus rules.md Paragraf 3. */
|
||
function chamfer(x, y, w, h, c) {
|
||
return "M" + n1(x + c) + "," + n1(y) +
|
||
"H" + n1(x + w - c) + "L" + n1(x + w) + "," + n1(y + c) +
|
||
"V" + n1(y + h - c) + "L" + n1(x + w - c) + "," + n1(y + h) +
|
||
"H" + n1(x + c) + "L" + n1(x) + "," + n1(y + h - c) +
|
||
"V" + n1(y + c) + "Z";
|
||
}
|
||
|
||
/* Schraffur als Schattierung: parallele Striche, an den Enden kuerzer, damit
|
||
das Feld nicht als Block steht. */
|
||
function schraff(x, y, w, h, abstand, neig) { return ; } // ohne Textur in dieser Fassung
|
||
|
||
/* 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) { return ; } // ohne Textur in dieser Fassung
|
||
|
||
/* ─── Bausteine ────────────────────────────────────────────────────────── */
|
||
/* Die Zeichenfarben folgen der Variante. Auf Dunkel traegt Tinte nicht, und
|
||
Gelb darf dort der volle Markenton sein statt des abgedunkelten. */
|
||
/* Drei Marken-Farben, dazu Tinte und eine Haarlinie. Teal war in rules.md
|
||
bisher als "ungenutzt" vermerkt und traegt hier den zweiten Akzent. */
|
||
/* Alle Werte aus app/globals.css der Website. GELB_DUNKEL ist die Textfarbe
|
||
auf gelbem Grund, GELB_HELL die aufgehellte Flaeche fuer Badges. */
|
||
let TINTE = "#1A1A1A", TEXT2 = "#555555", TEXT3 = "#888888",
|
||
GELB = "#F1B729", GELB_DUNKEL = "#3E2C0A", GELB_HELL = "#FFE082",
|
||
TEAL = "#0D6E6E", TEAL_HELL = "#E8F4F4",
|
||
ROT = "#E07B54", ROT_HELL = "#FBEAE4", // accent-orange der Website
|
||
LINIE = "#E5E5E5", KANTE = "#D5DBE1", KARTE = "#FFFFFF",
|
||
GRUND = "#F6F8FA"; // --container-bg, der Boden unter einem Zeichen
|
||
function farbenNachziehen() {
|
||
const d = variant === "dunkel";
|
||
TINTE = d ? "#FFFFFF" : "#1A1A1A";
|
||
TEXT2 = d ? "#C8D0DC" : "#555555";
|
||
TEXT3 = d ? "#8C97A8" : "#888888";
|
||
TEAL = d ? "#4FBEBE" : "#0D6E6E";
|
||
TEAL_HELL = d ? "rgba(79,190,190,.16)" : "#E8F4F4";
|
||
ROT = "#E07B54";
|
||
ROT_HELL = d ? "rgba(224,123,84,.18)" : "#FBEAE4";
|
||
LINIE = d ? "rgba(255,255,255,.14)" : "#E5E5E5";
|
||
KANTE = d ? "rgba(255,255,255,.22)" : "#D5DBE1";
|
||
KARTE = d ? "#1A2740" : "#FFFFFF";
|
||
GRUND = d ? "#222F49" : "#F6F8FA";
|
||
}
|
||
|
||
/* Der Chamfer der Marke: 16 px Schnitt, wie in lib/chamfer.ts der Website.
|
||
Dort steht auch, warum eine chamferte Flaeche keinen Rand bekommt --
|
||
clip-path beschneidet ihn mit. Also fuellen statt umranden. */
|
||
const CHAMFER_C = 16;
|
||
|
||
/* Weicher Schatten. Bis hierhin waren die Posts schattenfrei (rules.md 5),
|
||
fuer das Theme modern ist das aufgehoben: ohne ihn stehen die Kaesten
|
||
gestochen auf dem Grund, statt darueber zu schweben.
|
||
|
||
Als SVG-Filter und nicht als box-shadow -- die Kaesten sind gezeichnet
|
||
und nicht im DOM, und ein CSS-Schatten wuerde vom clip-path ohnehin
|
||
mit beschnitten (lib/chamfer.ts der Website). Die doppelte id in
|
||
mehreren Blaettern stoert nicht: alle Filter sind gleich, und url(#..)
|
||
nimmt den ersten. */
|
||
const SCHATTEN_DEFS =
|
||
'<defs><filter id="hgschatten" x="-35%" y="-35%" width="170%" height="170%">' +
|
||
'<feDropShadow dx="0" dy="7" stdDeviation="10" flood-color="#101828" ' +
|
||
'flood-opacity="0.14"/></filter></defs>';
|
||
|
||
// Eine Karte: Flaeche, Haarlinie, Schatten. Das ist die Grundform des Themes.
|
||
const karteFlaeche = (x, y, w, h, c, fuell) =>
|
||
'<g filter="url(#hgschatten)"><path d="' +
|
||
chamfer(x, y, w, h, c == null ? CHAMFER_C : c) + '" fill="' + (fuell || KARTE) +
|
||
'" stroke="' + KANTE + '" stroke-width="1.2"/></g>';
|
||
|
||
/* Die G-Marke, Punkte woertlich aus assets/brand/g-mark.svg. Als Pfad und
|
||
nicht als Bild: ein PNG im foreignObject kann beim Export verschwinden
|
||
(rules.md §7), ein Pfad nie. Der Massstab braucht mehr als eine
|
||
Nachkommastelle, sonst schrumpft die Marke sichtbar. */
|
||
const G_PUNKTE = "384.5,166.5 336.4,83.2 192.2,166.5 288.3,166.5 240.3,249.7 " +
|
||
"144.2,249.7 96.1,166.5 144.2,83.2 336.4,83.2 298,16.8 288.3,0 96.1,0 " +
|
||
"0,166.5 96.1,332.9 288.3,332.9";
|
||
function gMarke(cx, cy, hoehe, farbe) {
|
||
const s = hoehe / 332.9;
|
||
return '<g transform="translate(' + n1(cx - 384.5 * s / 2) + ',' + n1(cy - hoehe / 2) +
|
||
') scale(' + s.toFixed(5) + ')"><polygon points="' + G_PUNKTE + '" fill="' +
|
||
farbe + '"/></g>';
|
||
}
|
||
const chamferFlaeche = (x, y, w, h, fuell, c, rand) =>
|
||
'<path d="' + chamfer(x, y, w, h, c == null ? CHAMFER_C : c) + '" fill="' + fuell + '"' +
|
||
(rand ? ' stroke="' + KANTE + '" stroke-width="1.5"' : "") + '/>';
|
||
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 + '"' : '') + '/>';
|
||
};
|
||
/* Gefuellte Flaeche aus Eckpunkten. In der Skizzenfassung wird ausgemalt,
|
||
hier gefuellt. */
|
||
const flaeche = (pts, farbe, deck) =>
|
||
'<path d="M' + pts.map(q => n1(q[0]) + "," + n1(q[1])).join("L") + 'Z" fill="' +
|
||
farbe + '"' + (deck ? ' fill-opacity="' + deck + '"' : "") + '/>';
|
||
|
||
/* Bodenschatten entfallen: rules.md Paragraf 5 sagt schattenfrei, und in der
|
||
Skizzenfassung waren sie nur als Strichlage vertretbar. */
|
||
const schatten = () => "";
|
||
|
||
/* ─── 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) { return ; } // ohne Textur in dieser Fassung
|
||
|
||
/* 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) { return ; } // ohne Textur in dieser Fassung
|
||
|
||
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 });
|
||
const k = ""; // keine Fensterpunkte in dieser Fassung
|
||
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 + '">Dein Programm</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", i: "mail" },
|
||
{ x: 104, y: 200, t: "Zettel im Auto", i: "sticky-note" },
|
||
{ x: 48, y: 340, t: "Tabellen", i: "table" },
|
||
{ x: 128, y: 480, t: "Fotos auf dem Handy", i: "smartphone" },
|
||
{ x: 60, y: 620, t: "Ordner im Regal", i: "folder" },
|
||
];
|
||
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: .85, strichel: "11 13" });
|
||
|
||
chips.forEach(c => {
|
||
const y = c.y - HOCH / 2;
|
||
raus += karteFlaeche(c.x, y, c.w, HOCH, 13);
|
||
raus += feather(c.i, c.x + 34, c.y, 30, TINTE, 2);
|
||
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">' + SCHATTEN_DEFS + 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. */
|
||
/* Ein sauberer Chamfer-Rahmen, dazu Eckwinkel in Gelb. Die Skizzenfassung
|
||
hat hier einen versetzten Zweitzug mit schraffiertem Band -- ein
|
||
gezeichneter Schatten. Der passt nicht zu dieser Richtung; Eckwinkel
|
||
sind die Geste, die ein Bild fasst, ohne es zu umranden. */
|
||
const RX0 = 134, RY0 = 164, RW = 652, RH = 807, RC = 30, WL = 56;
|
||
let winkel = "";
|
||
[[RX0 + RC, RY0, WL, 0], [RX0, RY0 + RC, 0, WL],
|
||
[RX0 + RW - RC, RY0, -WL, 0], [RX0 + RW, RY0 + RC, 0, WL],
|
||
[RX0 + RC, RY0 + RH, WL, 0], [RX0, RY0 + RH - RC, 0, -WL],
|
||
[RX0 + RW - RC, RY0 + RH, -WL, 0], [RX0 + RW, RY0 + RH - RC, 0, -WL]]
|
||
.forEach(v => { winkel += strich(v[0], v[1], v[0] + v[2], v[1] + v[3]); });
|
||
|
||
const rahmen = zug(chamfer(RX0, RY0, RW, RH, RC), { w: 2.6, op: .55 }) +
|
||
zug(winkel, { f: GELB, w: 5 });
|
||
|
||
const rand = ""; // keine Randnotizen in dieser Fassung
|
||
|
||
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. */
|
||
/* ─── Slide 3 · Der Wechsel ─────────────────────────────────────────────
|
||
Kein Bild fuer Chaos, sondern der Vorgang selbst: eine Migration, die
|
||
nicht durchlaeuft. Fortschrittsbalken, drei Statuszeilen, geschaetzte
|
||
Dauer -- die Sprache, die unsere eigene Oberflaeche spricht (siehe die
|
||
Lauf-Liste im Pitch-Screen 1, wo fehlgeschlagene Laeufe genauso stehen).
|
||
|
||
Die Vorfassungen waren Metaphern: gerissene Leine, Sackkarre, Knaeuel.
|
||
Ein Betrieb, der ueber einen Wechsel nachdenkt, versteht eine
|
||
abgebrochene Uebernahme sofort und braucht dafuer kein Sinnbild. */
|
||
function visWechseln() {
|
||
const W = 896, H = 700, P = 56;
|
||
const zeilen = [
|
||
{ t: "Stammdaten", s: "übertragen", art: "ok" },
|
||
{ t: "Aufträge", s: "1.284 Konflikte", art: "warn" },
|
||
{ t: "Buchhaltung", s: "abgebrochen", art: "fehler" },
|
||
];
|
||
const ton = { ok: [TEAL_HELL, TEAL], warn: [GELB_HELL, GELB_DUNKEL], fehler: [ROT_HELL, ROT] };
|
||
|
||
let raus = karteFlaeche(0, 0, W, H);
|
||
|
||
raus += '<text x="' + P + '" y="86" font-family="Manrope, sans-serif" font-size="20" ' +
|
||
'font-weight="700" letter-spacing="0.16em" fill="' + TEXT3 + '">MIGRATION</text>';
|
||
raus += '<text x="' + P + '" y="152" font-family="Manrope, sans-serif" font-size="38" ' +
|
||
'font-weight="800" letter-spacing="-0.025em" fill="' + TINTE + '">Alles mitnehmen</text>';
|
||
|
||
// Fortschritt: er bleibt stehen, das ist die Aussage.
|
||
const BY = 248, BW = W - 2 * P;
|
||
raus += '<text x="' + P + '" y="' + (BY - 14) + '" font-family="Manrope, sans-serif" ' +
|
||
'font-size="24" font-weight="600" fill="' + TEXT2 + '">Fortschritt</text>';
|
||
raus += '<text x="' + (W - P) + '" y="' + (BY - 14) + '" text-anchor="end" ' +
|
||
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" fill="' + TINTE +
|
||
'">34 %</text>';
|
||
raus += '<rect x="' + P + '" y="' + BY + '" width="' + BW + '" height="12" rx="6" fill="' +
|
||
LINIE + '"/>';
|
||
raus += '<rect x="' + P + '" y="' + BY + '" width="' + n1(BW * 0.34) + '" height="12" rx="6" fill="' +
|
||
GELB + '"/>';
|
||
|
||
zeilen.forEach((z, i) => {
|
||
const y = 366 + i * 96, f = ton[z.art];
|
||
raus += '<circle cx="' + (P + 17) + '" cy="' + (y - 9) + '" r="17" fill="' + f[0] + '"/>';
|
||
const cx = P + 17, cy = y - 9;
|
||
const zeichen = { ok: "check", warn: "alert-triangle", fehler: "x" }[z.art];
|
||
raus += feather(zeichen, cx, cy, 19, f[1], 2.6);
|
||
raus += '<text x="' + (P + 52) + '" y="' + y + '" font-family="Manrope, sans-serif" ' +
|
||
'font-size="30" font-weight="700" letter-spacing="-0.015em" fill="' + TINTE + '">' +
|
||
z.t + '</text>';
|
||
raus += '<text x="' + (W - P) + '" y="' + y + '" text-anchor="end" ' +
|
||
'font-family="Manrope, sans-serif" font-size="26" font-weight="600" fill="' +
|
||
(z.art === "fehler" ? ROT : TEXT2) + '">' + z.s + '</text>';
|
||
if (i < zeilen.length - 1) {
|
||
raus += '<rect x="' + P + '" y="' + (y + 38) + '" width="' + BW + '" height="1" fill="' +
|
||
LINIE + '"/>';
|
||
}
|
||
});
|
||
|
||
/* Noahs eigenes Argument als Fusszeile: kein Chef faehrt seinen Betrieb
|
||
ein halbes Jahr an die Wand. */
|
||
raus += '<text x="' + P + '" y="' + (H - 54) + '" font-family="Manrope, sans-serif" ' +
|
||
'font-size="24" font-weight="600" fill="' + TEXT3 + '">Geschätzte Dauer</text>';
|
||
raus += '<text x="' + (W - P) + '" y="' + (H - 54) + '" text-anchor="end" ' +
|
||
'font-family="Manrope, sans-serif" font-size="24" font-weight="800" fill="' + TINTE +
|
||
'">ein halbes Jahr</text>';
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
|
||
}
|
||
|
||
/* ─── Feather-Icons ──────────────────────────────────────────────────────
|
||
Die Pfade stammen aus @subframe/core, dem Icon-Satz, mit dem unsere
|
||
Oberflaeche gebaut ist (siehe hivegrid-pitch). 24x24-Raster, 2 px Strich,
|
||
runde Enden. Selbst gezeichnete Zeichen sahen daneben immer eine Spur
|
||
anders aus; das hier ist dieselbe Hand.
|
||
|
||
Die Strichstaerke wird durch den Massstab geteilt, damit sie bei jeder
|
||
Groesse gleich schwer aussieht -- sonst wird ein grosses Icon fett. */
|
||
const FEATHER = {
|
||
"alert-triangle": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||
"clipboard-list": '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>',
|
||
"columns-3": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/>',
|
||
"file-text": '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/>',
|
||
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
|
||
"folder": '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||
"image": '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||
"mail": '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||
"megaphone": '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
|
||
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||
"smartphone": '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||
"sparkles": '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>',
|
||
"sticky-note": '<path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M15 3v6h6"/>',
|
||
"table": '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
|
||
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||
"workflow": '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
|
||
"x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||
"rocket": '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'
|
||
};
|
||
|
||
function feather(name, cx, cy, groesse, farbe, staerke) {
|
||
const s = groesse / 24;
|
||
return '<g transform="translate(' + n1(cx - groesse / 2) + ',' + n1(cy - groesse / 2) +
|
||
') scale(' + n1(s) + ')" fill="none" stroke="' + farbe + '" stroke-width="' +
|
||
n1((staerke == null ? 2 : staerke) / s) +
|
||
'" stroke-linecap="round" stroke-linejoin="round">' + FEATHER[name] + '</g>';
|
||
}
|
||
|
||
/* ─── Formen fuer die zugespitzte Fassung ───────────────────────────────
|
||
Workflow-Engine, Vertrieb und Marketing brauchen eigene Zeichen: die
|
||
bestehenden zeigen Dinge, die ein Branchenprogramm schon kann.
|
||
|
||
Jede Form nimmt einen Massstab entgegen und taugt damit als kleines
|
||
Listenzeichen und als grosser Satellit, sonst braeuchte es sechs
|
||
Funktionen statt drei. */
|
||
function formWorkflow(cx, cy, s) {
|
||
const r = 7 * s, dx = 20 * s, dy = 14 * s;
|
||
return kringel(cx - dx, cy, r) + kringel(cx + dx, cy - dy, r) + kringel(cx + dx, cy + dy, r) +
|
||
strich(cx - dx + r, cy - r * 0.3, cx + dx - r, cy - dy + r * 0.5) +
|
||
strich(cx - dx + r, cy + r * 0.3, cx + dx - r, cy + dy - r * 0.5);
|
||
}
|
||
|
||
function formTrichter(cx, cy, s) {
|
||
const b = 20 * s, h = 15 * s;
|
||
return "M" + n1(cx - b) + "," + n1(cy - h) + "H" + n1(cx + b) +
|
||
"L" + n1(cx + b * 0.26) + "," + n1(cy + h * 0.2) +
|
||
"V" + n1(cy + h) + "L" + n1(cx - b * 0.26) + "," + n1(cy + h * 0.55) +
|
||
"V" + n1(cy + h * 0.2) + "Z";
|
||
}
|
||
|
||
function formMegafon(cx, cy, s) {
|
||
const b = 18 * s, h = 15 * s;
|
||
return "M" + n1(cx - b) + "," + n1(cy - h * 0.34) +
|
||
"L" + n1(cx + b * 0.28) + "," + n1(cy - h) +
|
||
"V" + n1(cy + h) + "L" + n1(cx - b) + "," + n1(cy + h * 0.34) + "Z" +
|
||
strich(cx + b * 0.56, cy - h * 0.46, cx + b * 0.94, cy - h * 0.72) +
|
||
strich(cx + b * 0.62, cy, cx + b * 1.02, cy) +
|
||
strich(cx + b * 0.56, cy + h * 0.46, cx + b * 0.94, cy + h * 0.72);
|
||
}
|
||
|
||
const glyWorkflow = (cx, cy) => formWorkflow(cx, cy, 0.95);
|
||
const glyTrichter = (cx, cy) => formTrichter(cx, cy, 1.05);
|
||
const glyMegafon = (cx, cy) => formMegafon(cx, cy, 1.05);
|
||
|
||
/* ─── 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() {
|
||
/* Theme modern: kein Notizzettel. Die Aufzaehlung bleibt, wird aber zur
|
||
Kette aus Karten -- jede mit Zeichenkachel, Beschriftung und Haken,
|
||
dazwischen ein Verbinder mit Knoten. Das ist die Form, die Zapier fuer
|
||
Ablaeufe benutzt, und sie sagt hier zusaetzlich etwas: die vier Dinge
|
||
haengen zusammen, statt nur untereinander zu stehen. */
|
||
const W = 896, H = 730, KB = 636, KH = 116, LU = 44, KX = 130;
|
||
const punkte = [
|
||
{ t: "Workflow-Engine", i: "workflow" },
|
||
{ t: "Projektmanagement", i: "columns-3" },
|
||
{ t: "Chat", i: "message-square" },
|
||
{ t: "Mitarbeiter-Management", i: "users" },
|
||
];
|
||
const oben = (H - (punkte.length * KH + (punkte.length - 1) * LU)) / 2;
|
||
const kachelGrund = TEAL_HELL;
|
||
|
||
let raus = "";
|
||
punkte.forEach((p, i) => {
|
||
const y = oben + i * (KH + LU), mx = KX + 60, my = y + KH / 2;
|
||
|
||
// Verbinder zur naechsten Karte, mit Knoten in der Mitte.
|
||
if (i < punkte.length - 1) {
|
||
const vy = y + KH, vm = vy + LU / 2;
|
||
raus += zug(strich(mx, vy, mx, vy + LU), { f: TEAL, w: 2, op: .32 });
|
||
raus += '<circle cx="' + mx + '" cy="' + n1(vm) + '" r="13" fill="' + TEAL_HELL + '"/>';
|
||
raus += zug(strich(mx - 6, vm, mx + 6, vm) + strich(mx, vm - 6, mx, vm + 6),
|
||
{ f: TEAL, w: 2.4 });
|
||
}
|
||
|
||
raus += karteFlaeche(KX, y, KB, KH);
|
||
raus += chamferFlaeche(KX + 28, y + 26, 64, 64, kachelGrund, 10);
|
||
raus += feather(p.i, KX + 60, y + 58, 34, TEAL, 2.2);
|
||
raus += '<text x="' + (KX + 118) + '" y="' + n1(my + 12) + '" ' +
|
||
'font-family="Manrope, sans-serif" font-size="34" font-weight="700" ' +
|
||
'letter-spacing="-0.02em" fill="' + TINTE + '">' + p.t + '</text>';
|
||
|
||
// Haken rechts: gefuellter Kreis in Orange, wie bei Autarc.
|
||
const hx = KX + KB - 48;
|
||
raus += '<circle cx="' + hx + '" cy="' + n1(my) + '" r="18" fill="' + GELB + '"/>';
|
||
raus += zug(glatt([[hx - 9, my], [hx - 3, my + 7], [hx + 9, my - 8]], false),
|
||
{ f: "#3E2C0A", w: 3.6 });
|
||
});
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</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. */
|
||
/* ─── Slide 6 · So geht es weiter ────────────────────────────────────────
|
||
Nachbau der Sektion WaitlistNext.tsx von der Website, nicht eine
|
||
entschraffierte Zeichnung. Alle Werte kommen von dort und sind mit 1,6
|
||
hochskaliert (die Liste ist dort 560 breit, hier 896):
|
||
|
||
Wabe 54 x 48 -> 86 x 77, flat-top wie im clip-path
|
||
Schiene links 26 -> 43, also die Wabenmitte, 2 px -> 3
|
||
Abstand 56 -> 90
|
||
Nummer 13 px -> 21, Sperrung 0,14em
|
||
Titel 20 px -> 32
|
||
Text 14,5 px -> 23
|
||
|
||
Auf der Website leuchten die Waben nacheinander auf, waehrend man
|
||
scrollt; der dritte Schritt ist dort blass, weil er noch nicht erreicht
|
||
ist. Ein Standbild kennt keinen Scrollstand, deshalb sind hier alle drei
|
||
aktiv. */
|
||
function visWarteliste() {
|
||
const W = 896, H = 600;
|
||
const HB = 86, HH = 77, HX = 0, TX = 120, ABSTAND = 84;
|
||
|
||
const schritte = [
|
||
{ t: "Du trägst dich ein", ikon: "brief",
|
||
z: ["Eine E-Mail genügt. Kein Vertrag,", "keine Kreditkarte, unverbindlich."] },
|
||
{ t: "Wir melden uns", ikon: "chat",
|
||
z: ["Sobald HiveGrid für deinen Betrieb", "bereit ist, persönlich und ohne Spam."] },
|
||
{ t: "Du bist von Anfang an dabei", ikon: "rakete",
|
||
z: ["Unter den Ersten, die HiveGrid", "nutzen und mitgestalten."] },
|
||
];
|
||
|
||
const zeilenHoehe = 37, blockHoehe = 142;
|
||
const oben = (H - (schritte.length * blockHoehe + (schritte.length - 1) * ABSTAND)) / 2;
|
||
|
||
// Flat-top-Wabe in den Proportionen des clip-path der Website.
|
||
const wabeForm = (x, y) =>
|
||
"M" + n1(x + HB * .25) + "," + y + "H" + n1(x + HB * .75) +
|
||
"L" + n1(x + HB) + "," + n1(y + HH / 2) +
|
||
"L" + n1(x + HB * .75) + "," + n1(y + HH) +
|
||
"H" + n1(x + HB * .25) + "L" + x + "," + n1(y + HH / 2) + "Z";
|
||
|
||
const zeichen = {
|
||
brief: (cx, cy) => {
|
||
const w = 36, h = 26, x = cx - w / 2, y = cy - h / 2;
|
||
return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h +
|
||
'" rx="4" fill="none" stroke="#171106" stroke-width="2.6"/>' +
|
||
'<path d="M' + (x + 2) + ',' + (y + 4) + 'L' + cx + ',' + (y + h * .6) +
|
||
'L' + (x + w - 2) + ',' + (y + 4) + '" fill="none" stroke="#171106" ' +
|
||
'stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/>';
|
||
},
|
||
chat: (cx, cy) => {
|
||
const w = 36, h = 28, x = cx - w / 2, y = cy - h / 2 - 3;
|
||
return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h +
|
||
'" rx="9" fill="none" stroke="#171106" stroke-width="2.6"/>' +
|
||
'<path d="M' + (x + 9) + ',' + (y + h) + 'l-2,9l11,-9z" fill="#171106"/>' +
|
||
[-8, 0, 8].map(o => '<circle cx="' + (cx + o) + '" cy="' + (y + h / 2) +
|
||
'" r="2.4" fill="#171106"/>').join("");
|
||
},
|
||
/* Das selbst gezeichnete Konfetti war ein Kleckshaufen. Hier steht
|
||
dieselbe Aussage als Zeichen aus dem Satz der Software. */
|
||
rakete: (cx, cy) => feather("rocket", cx, cy, 34, "#171106", 2.4),
|
||
};
|
||
|
||
let raus = "";
|
||
const mitte = i => oben + i * (blockHoehe + ABSTAND) + HH / 2;
|
||
|
||
// Die Schiene liegt hinter den Waben und verbindet ihre Mitten.
|
||
raus += '<rect x="' + n1(HX + HB / 2 - 1.5) + '" y="' + n1(mitte(0)) + '" width="3" height="' +
|
||
n1(mitte(schritte.length - 1) - mitte(0)) + '" rx="1.5" fill="' + GELB + '"/>';
|
||
|
||
schritte.forEach((sc, i) => {
|
||
const y = oben + i * (blockHoehe + ABSTAND);
|
||
raus += '<path d="' + wabeForm(HX, y) + '" fill="' + GELB + '"/>';
|
||
raus += zeichen[sc.ikon](HX + HB / 2, y + HH / 2);
|
||
raus += '<text x="' + TX + '" y="' + n1(y + 24) + '" font-family="Manrope, sans-serif" ' +
|
||
'font-size="32" font-weight="700" letter-spacing="-0.015em" fill="' + TINTE + '">' +
|
||
sc.t + '</text>';
|
||
sc.z.forEach((z, k) => {
|
||
raus += '<text x="' + TX + '" y="' + n1(y + 76 + k * zeilenHoehe) + '" ' +
|
||
'font-family="Manrope, sans-serif" font-size="23" font-weight="500" fill="' +
|
||
TEXT2 + '">' + z + '</text>';
|
||
});
|
||
});
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
|
||
}
|
||
|
||
/* ─── Slide 5 · Die Loesung ──────────────────────────────────────────────
|
||
Nachbau des Architektur-Diagramms aus dem Pitch-Paket (Screen 0), nicht
|
||
eine entschraffierte Wabe. Dort sitzt der Kern als gefuellter Kreis in
|
||
der Mitte, ein farbiger Ring liegt darum und traegt oben eine Pille mit
|
||
dem Produktnamen, und die andockenden Module stehen als Kacheln aussen.
|
||
|
||
Das Diagramm sagt genau das, was die Ueberschrift sagt: der Kern bleibt,
|
||
der Ring kommt drumherum. Meine Wabe hat dieselbe Aussage transportiert,
|
||
aber in einer Form, die es in unserer Software nirgends gibt. */
|
||
function visVerbinden() {
|
||
const W = 988, H = 730, CX = 494, CY = 368;
|
||
const KERN = 104, RING = 202, SATX = 344, SATY = 262, KACHEL = 88;
|
||
|
||
const module = [
|
||
{ a: 240, t: "KI", i: "sparkles" },
|
||
{ a: 300, t: "Chat", i: "message-square" },
|
||
{ a: 0, t: "Mitarbeiter", i: "users" },
|
||
{ a: 60, t: "Automation", i: "zap" },
|
||
{ a: 120, t: "Projekte", i: "columns-3" },
|
||
{ a: 180, t: "Formulare", i: "clipboard-list" },
|
||
];
|
||
// KI hat kein eigenes Zeichen im Satz, deshalb der Funke von Slide 5 alt.
|
||
module[0].ikon = (x, y) => funkeForm(x, y, 20);
|
||
|
||
let raus = "";
|
||
|
||
// Der Ring: HiveGrid liegt um den Kern, nicht an seiner Stelle.
|
||
raus += '<circle cx="' + CX + '" cy="' + CY + '" r="' + RING + '" fill="none" stroke="' +
|
||
GELB + '" stroke-width="3"/>';
|
||
|
||
// Speichen und Kacheln.
|
||
module.forEach(m => {
|
||
const r = m.a * Math.PI / 180;
|
||
const sx = CX + Math.cos(r) * SATX, sy = CY + Math.sin(r) * SATY;
|
||
const vx = sx - CX, vy = sy - CY, vl = Math.hypot(vx, vy) || 1;
|
||
const ux = vx / vl, uy = vy / vl;
|
||
raus += '<line x1="' + n1(CX + ux * RING) + '" y1="' + n1(CY + uy * RING) +
|
||
'" x2="' + n1(sx - ux * (KACHEL / 2 + 10)) + '" y2="' + n1(sy - uy * (KACHEL / 2 + 10)) +
|
||
'" stroke="' + LINIE + '" stroke-width="2"/>';
|
||
raus += '<circle cx="' + n1(CX + ux * RING) + '" cy="' + n1(CY + uy * RING) +
|
||
'" r="7" fill="' + GELB + '"/>';
|
||
raus += karteFlaeche(sx - KACHEL / 2, sy - KACHEL / 2, KACHEL, KACHEL, 12, GRUND);
|
||
raus += feather(m.i, sx, sy, 42, GELB, 2.3);
|
||
const ty = m.a > 180 ? sy - KACHEL / 2 - 16 : sy + KACHEL / 2 + 34;
|
||
raus += '<text x="' + n1(sx) + '" y="' + n1(ty) + '" text-anchor="middle" ' +
|
||
'font-family="Manrope, sans-serif" font-size="24" font-weight="700" ' +
|
||
'letter-spacing="-0.01em" fill="' + TINTE + '">' + m.t + '</text>';
|
||
});
|
||
|
||
// Der Kern. Gefuellt und dunkel, damit er als das Bestehende liest.
|
||
raus += '<circle cx="' + CX + '" cy="' + CY + '" r="' + KERN + '" fill="' +
|
||
(variant === "dunkel" ? "#1A2740" : "#1A1A1A") + '"/>';
|
||
raus += '<text x="' + CX + '" y="' + (CY - 34) + '" text-anchor="middle" ' +
|
||
'font-family="Manrope, sans-serif" font-size="16" font-weight="700" ' +
|
||
'letter-spacing="0.18em" fill="rgba(255,255,255,.5)">KERN</text>';
|
||
["Dein", "Programm"].forEach((z, i) => {
|
||
raus += '<text x="' + CX + '" y="' + (CY + 6 + i * 34) + '" text-anchor="middle" ' +
|
||
'font-family="Manrope, sans-serif" font-size="30" font-weight="800" ' +
|
||
'letter-spacing="-0.02em" fill="#FFFFFF">' + z + '</text>';
|
||
});
|
||
|
||
/* Die Pille auf dem Ring, wie im Pitch-Diagramm. Sie sitzt auf der Bahn
|
||
und macht damit sichtbar, dass HiveGrid der Ring ist und nicht die
|
||
Mitte. */
|
||
const PB = 94, PH = 72, PX = CX - PB / 2, PY = CY - RING - PH / 2;
|
||
raus += karteFlaeche(PX, PY, PB, PH, 14);
|
||
raus += gMarke(CX, CY - RING, 42, GELB);
|
||
|
||
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + raus + '</svg>';
|
||
}
|
||
|
||
/* Vierzackiger Funke fuer KI, praezise gezogen. */
|
||
function funkeForm(cx, cy, r) {
|
||
const k = r * 0.3, p = [];
|
||
for (let i = 0; i < 8; i++) {
|
||
const a = -Math.PI / 2 + i * Math.PI / 4, rr = i % 2 ? k : r;
|
||
p.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]);
|
||
}
|
||
return glatt(p, true);
|
||
}
|
||
|
||
/* ═══ 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-chip">Vierte Generation · SHK</div>' +
|
||
'<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 class="marke">' +
|
||
'<svg viewBox="0 0 384.5 332.9" xmlns="http://www.w3.org/2000/svg">' +
|
||
'<polygon fill="#F1B729" points="' + G_PUNKTE + '"/></svg></span><span class="zeilen">' +
|
||
'<b>Noah Geerkens</b><small>Gründer</small></span></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: 76, 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";
|
||
let bandTon = "orange", rasterAn = "aus", textMitte = "aus";
|
||
|
||
/* ─── 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. */
|
||
/* ─── Theme ───────────────────────────────────────────────────────────────
|
||
Neben dem Projekt (welcher Beitrag) gibt es das Theme (wie er aussieht).
|
||
|
||
notebook Rasterhintergrund, Zeichenblock-Optik, Marker in der
|
||
Ueberschrift, handgezogene Formen. Liegt in post2.html.
|
||
modern Ruhiger Grund ohne Textur, sehr grosse Ueberschrift mit einem
|
||
farbigen Wort statt Marker, schwebende Karten mit Haarlinie,
|
||
praezise Formen. Diese Datei.
|
||
|
||
Beide teilen Aufbau, Texte und Kompositionen. Wer eine Visualisierung
|
||
aendert, muss an beide Dateien denken. */
|
||
const THEME = "modern";
|
||
const PROJECT = "post2-alt";
|
||
const STORE = "hivegrid.projekt." + PROJECT;
|
||
const STATE_URL = "./state/" + PROJECT + ".json";
|
||
|
||
const STATE_V = 7;
|
||
|
||
function currentState() {
|
||
return {
|
||
v: STATE_V, variant, tilt, superSample, view, tagOut, frameStyle, bandTon,
|
||
rasterAn, textMitte,
|
||
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; }
|
||
}
|
||
/* Stand 7: Die Visualisierungen haben andere Masse bekommen -- das Foto
|
||
auf Slide 1 ist quadratisch und fuellt die Inhaltsspalte, Slide 3 ist
|
||
120 px hoeher, Slide 5 hat wieder ihre volle Breite. Die gespeicherten
|
||
Verschiebungen und Groessen stammen aus der Zeit davor und wuerden die
|
||
Elemente aus dem Blatt schieben. Das gehoert in den Ladepfad und nicht
|
||
in die Datei: der offene Tab schreibt die Datei sonst zurueck
|
||
(POST-2.md 6). */
|
||
if (alt < 7 && Array.isArray(d.edits)) {
|
||
for (const e of d.edits) { if (e) delete e.vis; }
|
||
}
|
||
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("bands", "b", bandTon);
|
||
press("rasters", "r", rasterAn);
|
||
press("mitten", "m", textMitte);
|
||
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;
|
||
bandTon = d.bandTon || bandTon;
|
||
rasterAn = d.rasterAn || rasterAn;
|
||
textMitte = d.textMitte || textMitte;
|
||
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>';
|
||
|
||
|
||
/* ─── Das Band ──────────────────────────────────────────────────────────
|
||
Die Wellenlaenge ist mit Absicht kein Vielfaches von 1080. Waere sie es,
|
||
laege an jeder Naht derselbe Punkt der Welle -- meist ein Scheitel, und
|
||
ein waagerechtes Band an der Kante sagt dem Auge nichts. So trifft jede
|
||
Naht eine andere Stelle der Flanke.
|
||
|
||
Der Querschnitt ist ein Rohr, kein Farbband. Die Rampe unten ist das,
|
||
was einen Metallzylinder von einem Bonbon unterscheidet:
|
||
|
||
- Die Glanzstelle liegt NICHT an der Oberkante, sondern eingerueckt
|
||
bei etwa 20 %, und sie ist schmal und fast weiss. Ein breites,
|
||
weiches Hellgelb obenauf ist genau der Bonbon-Eindruck.
|
||
- Der dunkelste Punkt liegt NICHT an der Unterkante, sondern bei
|
||
etwa 78 %. Darunter kommt Licht vom weissen Grund zurueck.
|
||
- Der Tonumfang ist gross: von #6E4205 bis fast Weiss. Metall hat
|
||
Kontrast, lackierter Kunststoff nicht.
|
||
- Beide Silhouetten sind aufgehellt (Fresnel): streifendes Licht
|
||
wird staerker reflektiert.
|
||
- Ein paar Laengsstreifen als Schliff. Ohne die bleibt die Oberflaeche
|
||
zu glatt, um echt zu wirken.
|
||
|
||
Kein SVG-Verlauf: der laeuft immer geradlinig durch die Bounding-Box,
|
||
das Band wellt sich aber, er wuerde also quer statt laengs schattieren.
|
||
Versetzte Streifen folgen der Kurve von selbst.
|
||
|
||
Der Versatz ist senkrecht und nicht rechtwinklig zur Bahn. Dadurch wird
|
||
das Band an den Flanken um bis zu 14 % schmaler -- das liest sich als
|
||
Verkuerzung und ist erwuenscht, waehrend echte Parallelkurven an engen
|
||
Radien Selbstschnitte bekommen. */
|
||
/* ─── Das Band ──────────────────────────────────────────────────────────
|
||
Flach und deckend, wie die Formen bei Slack: eine kraeftige Flaeche,
|
||
dahinter eine blasse zweite als Tiefe. Kein Verlauf, keine Woelbung,
|
||
keine Glanzkante.
|
||
|
||
Davor stand hier ein metallisch schattierter Zylinder aus vierzig
|
||
Streifen. Der sah nach Foto aus und nach nichts, was zu einem flachen,
|
||
ruhigen Blatt passt. Wer ihn wiederhaben will, findet ihn im Commit
|
||
"Theme modern: Metallband, Icon-Satz und echte G-Marke".
|
||
|
||
Zwei Toene, umschaltbar. "teal" ist nicht --accent-teal, sondern
|
||
--dark-bg #30313D -- die Farbe des dunklen Abschnitts auf der Website. */
|
||
const BAND_TOENE = {
|
||
orange: { voll: "#F1B729", blass: "#FCE7B4" },
|
||
teal: { voll: "#30313D", blass: "#D3D5DE" },
|
||
};
|
||
|
||
/* Die Bahn steht als Stuetzpunkte da, nicht als Formel -- einer je halbe
|
||
Slide, in Streifen-Koordinaten. Eine Sinuskurve war kuerzer, liess sich
|
||
aber nicht steuern: auf Slide 5 lief sie quer durch die Kacheln. Jeder
|
||
Wert ist am gerenderten Bild geprueft, nicht gerechnet.
|
||
Slide 2 quer durch, die Kaesten liegen als Karten darueber
|
||
Slide 3 hinter der Karte, sichtbar in den Seitenraendern
|
||
Slide 4 zwischen den Kartenreihen hindurch
|
||
Slide 5 unter dem Diagramm, dessen Beschriftungen bei 1030 enden */
|
||
/* Von welcher Slide bis zu welcher das Band laeuft, jeweils als Anzahl
|
||
Slides ohne Band am Anfang und am Ende. Slide 1 und die letzte sollen
|
||
ruhig sein, Slide 2 traegt ihre eigene Leitung und wuerde sich mit dem
|
||
Band beissen. Bleibt Slide 3 bis 5. */
|
||
const BAND_VON = 2, BAND_BIS = 1;
|
||
/* Auf der ersten Slide mit Band faengt es nicht an der Kante an, sondern
|
||
unter der Karte: so kommt es dahinter hervor, statt links schon
|
||
dazuliegen. Der Wert ist ein Versatz in Slide-Koordinaten und liegt
|
||
hinter der Karte (die reicht bis 988). Die Kurve selbst bleibt an der
|
||
Kante verankert, sonst wuerden sich die Stuetzpunkte neu verteilen und
|
||
die Fuehrung auf den Folge-Slides verschieben. */
|
||
const BAND_ANSCHNITT = 540;
|
||
/* Am anderen Ende endet es an der Naht. Der Auslauf ist der Zeichenbereich
|
||
hinter dem Kurvenanker; die Kurve wird dort mit ihrer Endsteigung
|
||
geradlinig verlaengert, damit sie nicht abknickt. Ein Versuch mit 170
|
||
liess das Band in die letzte Slide hineinragen -- die soll ruhig sein. */
|
||
const BAND_AUSLAUF = 0;
|
||
const BAND_BAHN = [990, 900, 740, 690, 1130, 1275, 1215];
|
||
|
||
function bandSvg() {
|
||
/* Slide 1 und die letzte tragen kein Band: Anfang und Schluss sollen
|
||
ruhig sein. Es laeuft von der linken Kante der zweiten bis zur
|
||
rechten Kante der vorletzten Slide -- zwei Pixel innerhalb der Naht,
|
||
weil genau auf der Kante ein Antialiasing-Splitter auf der
|
||
Nachbarslide stehen bleibt. */
|
||
const H = 1350;
|
||
const A0 = BAND_VON * 1080 + 2; // Anker der Kurve
|
||
const A1 = Math.max(A0, (SLIDES.length - BAND_BIS) * 1080 - 2);
|
||
const X0 = Math.min(A1, BAND_VON * 1080 + BAND_ANSCHNITT); // ab hier gezeichnet
|
||
const X1 = A1 + BAND_AUSLAUF; // bis hierhin
|
||
const W = SLIDES.length * 1080;
|
||
const P = BAND_BAHN, SCHRITT = (A1 - A0) / (P.length - 1);
|
||
const ton = BAND_TOENE[bandTon] || BAND_TOENE.orange;
|
||
|
||
// Catmull-Rom: laeuft durch die Stuetzpunkte, nicht daran vorbei.
|
||
const kurve = x => {
|
||
const u = Math.max(0, Math.min(P.length - 1.0001, (x - A0) / SCHRITT));
|
||
const i = Math.floor(u), t = u - i;
|
||
const g = k => P[Math.max(0, Math.min(P.length - 1, k))];
|
||
const a0 = g(i - 1), a1 = g(i), a2 = g(i + 1), a3 = g(i + 2);
|
||
return 0.5 * (2 * a1 + (a2 - a0) * t +
|
||
(2 * a0 - 5 * a1 + 4 * a2 - a3) * t * t +
|
||
(-a0 + 3 * a1 - 3 * a2 + a3) * t * t * t);
|
||
};
|
||
// Hinter dem Anker geradlinig weiter, mit der Steigung am Endpunkt.
|
||
const steig = (kurve(A1) - kurve(A1 - 24)) / 24;
|
||
const y = x => x <= A1 ? kurve(x) : kurve(A1) + steig * (x - A1);
|
||
|
||
const xs = [];
|
||
for (let x = X0; x < X1; x += 45) xs.push(x);
|
||
xs.push(X1);
|
||
/* Gefuellte Flaeche statt dicker Linie: eine Linie bekommt an ihrem Ende
|
||
eine Kappe rechtwinklig zur Laufrichtung, und die steht bei schraeger
|
||
Bahn seitlich ueber die Naht hinaus -- auf der Nachbarslide blieb ein
|
||
Splitter stehen. Eine geschlossene Kontur endet exakt senkrecht. */
|
||
const kante = v => xs.map(x => x + "," + (y(x) + v).toFixed(1));
|
||
const flaeche = (v, h, f) => '<path d="M' + kante(v - h).join("L") + "L" +
|
||
kante(v + h).reverse().join("L") + '" fill="' + f + '"/>';
|
||
|
||
return '<svg class="layer-band" width="' + W + '" height="' + H + '" viewBox="0 0 ' +
|
||
W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg">' +
|
||
flaeche(29, 53.5, ton.blass) + flaeche(0, 44, ton.voll) + '</svg>';
|
||
}
|
||
|
||
// Positionen im 5400er Raum. Die Nähte liegen auf 1080 / 2160 / 3240 / 4320.
|
||
const bgHtml = () =>
|
||
'<div class="bg">' +
|
||
'<div class="layer-raster"></div>' +
|
||
bandSvg() +
|
||
'</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.raster = rasterAn;
|
||
el.dataset.mitte = textMitte;
|
||
el.dataset.frame = frameStyle;
|
||
el.dataset.editmode = editMode;
|
||
el.style.setProperty("--slide-i", i);
|
||
el.innerHTML = bgHtml();
|
||
|
||
/* 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() {
|
||
/* Erst die Zeichenfarben, dann die Seiten: die Visualisierungen sind in
|
||
diese Farben gezeichnet und muessen bei einem Variantenwechsel neu
|
||
entstehen. In der Skizzenfassung reichte es, nur Titel-Seiten neu zu
|
||
bauen, weil dort alles in einer Farbe stand. */
|
||
farbenNachziehen();
|
||
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.raster = rasterAn;
|
||
el.dataset.mitte = textMitte;
|
||
el.dataset.frame = frameStyle;
|
||
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;
|
||
}
|
||
|
||
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;
|
||
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(); }
|
||
});
|
||
/* Das Band haengt in .bg und wird von buildSlide gesetzt, nicht von
|
||
applyAll. Beim Umschalten wird es deshalb hier direkt getauscht. */
|
||
document.getElementById("rasters").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { rasterAn = b.dataset.r; press("rasters", "r", rasterAn); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("mitten").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { textMitte = b.dataset.m; press("mitten", "m", textMitte); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("bands").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
bandTon = b.dataset.b; press("bands", "b", bandTon);
|
||
document.querySelectorAll(".bg .layer-band").forEach(el => el.outerHTML = bandSvg());
|
||
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>
|