Bewegtbild der vier Teile mit Noah und David, dazu die Slide zum Anmelden. Termin, Uhrzeit und Dauer sind Platzhalter in WEBINAR. Die Uebersicht kennt Post 8 und Post 9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7949 lines
394 KiB
HTML
7949 lines
394 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 9 · Webinar</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);
|
||
}
|
||
|
||
/* ─── Der Punktgrund ─────────────────────────────────────────────────────
|
||
Die Arbeitsflaeche des Workflow-Editors. Werte aus dem eigenen Produkt,
|
||
nicht aus einer fremden Seite: components/sections/WorkflowEditor.tsx der
|
||
Website zeichnet ihren Canvas mit
|
||
radial-gradient(circle, #E5E7EB 1px, transparent 1px) bei 24 px
|
||
und legt ihn auf #FAFBFD.
|
||
|
||
Zwei Abweichungen davon, beide begruendet:
|
||
|
||
- Die Teilung ist verdoppelt, 48 statt 24 px. Im Feed steht die Slide auf
|
||
rund 400 px, also ein Drittel ihrer Breite; ein 24er Raster faellt dort
|
||
auf 9 px zusammen und wird zu grauem Schleier statt zu Punkten.
|
||
- Der Grund bleibt #FFFFFF und wird nicht auf #FAFBFD getoent. rules.md 1a
|
||
verbietet das ausdruecklich: im Profilraster stehen die Beitraege
|
||
nebeneinander, und ein leicht getoenter Grund faellt dort als Fehler
|
||
auf. Die Punkte liegen also auf Weiss.
|
||
|
||
Damit ist der Grund dieses Beitrags nicht Dekoration, sondern die Flaeche,
|
||
auf der die Knoten tatsaechlich liegen. Das ist der Grund, warum hier vom
|
||
ruhigen Grund des Themes modern abgewichen wird -- eine Textur nimmt
|
||
schwebenden Karten sonst den Halt, hier gibt sie ihnen einen Ort. */
|
||
.slide[data-raster="punkte"] .layer-raster {
|
||
background-image: radial-gradient(circle, #E5E7EB 2px, transparent 2px);
|
||
background-size: 48px 48px;
|
||
opacity: calc(var(--int-mul, 1) * 1.6);
|
||
}
|
||
.slide[data-raster="punkte"][data-variant="dunkel"] .layer-raster {
|
||
background-image: radial-gradient(circle, rgba(255,255,255,.20) 2px, transparent 2px);
|
||
opacity: calc(var(--int-mul, 1) * 1.3);
|
||
}
|
||
|
||
/* 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 Visualisierungen von Post 2 und die
|
||
gesamte Teamkarte samt Badge. Beides ist mit den Zeichnungen entfallen.
|
||
|
||
ACHTUNG, DAS IST EINMAL SCHIEFGEGANGEN: beim Entfernen blieben die
|
||
Regelrumpfe stehen, weil nur die Selektorzeilen weggeschnitten wurden --
|
||
rund neunzig Zeilen Deklarationen ohne Selektor, dazu ein Kommentar ohne
|
||
Ende. CSS faengt sich davon nicht wieder: der Parser ueberspringt bis zur
|
||
naechsten Marke, und dabei verschwand die Regel [data-edit] darunter.
|
||
Damit war das Verschieben von Elementen im Canvas tot, ohne dass die
|
||
Seite einen Fehler zeigte -- sichtbar erst daran, dass ein gespeicherter
|
||
Versatz nicht mehr ankam.
|
||
|
||
Wer hier Regeln entfernt: den Block als Ganzes nehmen, Selektor bis
|
||
schliessende Klammer, und danach im Browser nachsehen, ob
|
||
document.styleSheets noch alle Regeln kennt. */
|
||
|
||
|
||
/* ─── Animation: die Leiste unter dem Streifen ────────────────────────────
|
||
Sie gehoert zur Bedienoberflaeche und nicht zur Slide -- im Modus
|
||
?nur=streifen ist sie deshalb weg, wie alles andere auch. */
|
||
/* 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); white-space: nowrap;
|
||
}
|
||
/* nowrap gehoert zu den gesetzten <br> in den Ueberschriften: ohne das
|
||
bricht eine zu lange Zeile zusaetzlich um, und die Slide waechst
|
||
unbemerkt um eine Zeile. Steht als Falle in rules.md 8 und in
|
||
post3.html genauso. */
|
||
/* 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>
|
||
<button type="button" data-gehe="./post7.html">Post 7 · Form Builder</button>
|
||
<button type="button" data-gehe="./post8.html">Post 8 · E-Mail-Marketing</button>
|
||
<button type="button" data-gehe="./post9.html">Post 9 · Webinar</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 9 · Webinar</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="false">Grund leer</button>
|
||
<button data-r="punkte" aria-pressed="true">Punkte</button>
|
||
<button data-r="an" aria-pressed="false">Raster</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 Workflow-Engine ─────────────────────────────────────────────────
|
||
Nummer 3 im Redaktionsplan, im Generator das vierte Projekt. Carousel,
|
||
sechs Slides, Theme modern, Variante hell.
|
||
|
||
Der Beitrag zeigt einen einzigen echten Ablauf statt sechs Behauptungen
|
||
ueber Automatisierung. Grundlage ist assets/screens/workflow.png -- der
|
||
Vorgang, den die Engine dort tatsaechlich faehrt -- und die Bausteine
|
||
stammen aus ui/components/NodeWorkflowEngine des Pitch-Pakets sowie aus
|
||
lib/produkt-daten.ts der Website.
|
||
|
||
Kein Bildmaterial: alles ist gezeichnet. Damit bleibt es beim 3-fachen
|
||
Export so scharf wie die Schrift, und jede Zeichnung laesst sich im
|
||
Canvas verschieben wie jedes andere Element. */
|
||
/* ─── Die sechs Slides ────────────────────────────────────────────────────
|
||
Ein einziger echter Ablauf, ueber den Beitrag abgerollt. Nicht sechs
|
||
Behauptungen ueber Automatisierung, sondern der Vorgang, den die
|
||
Workflow-Engine im Screenshot assets/screens/workflow.png tatsaechlich
|
||
faehrt: Formular eingegangen, Daten vereinheitlichen, passender Lead,
|
||
und ab da zwei Wege.
|
||
|
||
Das ist woertlich die Vorgabe des Redaktionsplans. Dort steht in der
|
||
Tabelle "Statt behaupten, zeigen" die Zeile:
|
||
"Spart Zeit" -> Den Ablauf: Anfrage rein, Aufgabe raus, Bestaetigung
|
||
an den Kunden.
|
||
|
||
Zwei Dinge, die dabei zu beachten waren:
|
||
|
||
- Post 1 hat auf Slide 2 bereits "Wenn-dann statt Handarbeit." samt dem
|
||
Website-Fliesstext und beiden Screenshots. Genau diese Zeile ist im
|
||
Redaktionsplan auch das Ziel dieses Termins -- sie ist damit verbraucht
|
||
und darf hier nicht noch einmal stehen. Post 1 war die Uebersicht ueber
|
||
alle Module, dieser Beitrag macht eines davon auf. Wer beides
|
||
nacheinander sieht, muss etwas Neues lesen.
|
||
- Die Begriffe stammen aus dem Produkt, nicht aus der Werbung:
|
||
Ausloeser, Aktionen, Verzweigung und die Knotennamen stehen so in
|
||
ui/components/NodeWorkflowEngine des Pitch-Pakets und in
|
||
lib/produkt-daten.ts der Website.
|
||
|
||
Zeilenlaengen geprueft: bei 76 px und -0,032em passen rund 26 Zeichen in
|
||
eine Zeile. Die laengste hier ist "mit einem Ausloeser an." mit 22. */
|
||
/* ─── Die vier Slides ─────────────────────────────────────────────────────
|
||
Erzaehlt wird, was erzaehlt werden muss, und sonst nichts:
|
||
|
||
1 was es ist der Ablauf als Form
|
||
2 was es besonders die Verzweigung, wenn es nicht glatt laeuft
|
||
macht
|
||
3 wie man es baut zusammengeklickt, nicht programmiert
|
||
4 dass es lief der Verlauf mit Uhrzeiten
|
||
|
||
Ein frueherer Stand hatte sechs. Zwei davon waren Beiwerk: ein
|
||
Konfigurationsfeld und eine Vorlagenliste, beide richtig, beide nicht
|
||
noetig. Der Redaktionsplan nennt fuenf bis sieben Slides, aber das ist
|
||
eine Spanne und kein Soll -- eine Slide, die nichts hinzufuegt, kostet
|
||
einen Wisch und gibt nichts zurueck. Die beiden Zeichnungen bleiben in
|
||
VIS stehen, falls eine spaetere Slide sie doch braucht.
|
||
|
||
Jede Slide zeigt eine andere Ansicht derselben Software: Ablauf, Knoten,
|
||
Auswahl, Verlauf. Sechsmal dasselbe Canvas war das, was den vorigen Stand
|
||
eintoenig machte.
|
||
|
||
Zeilenlaengen geprueft: bei 76 px und -0,032em passen rund 26 Zeichen in
|
||
eine Zeile, die laengste hier hat 21. */
|
||
/* ─── Die drei Slides ─────────────────────────────────────────────────────
|
||
Erzaehlt wird, was erzaehlt werden muss:
|
||
|
||
1 was es ist der Ablauf als Form
|
||
2 wie man es baut Bausteine aus einer Liste
|
||
3 was es kann der Weg fuer den Fall, dass es nicht glatt laeuft
|
||
|
||
Alles, was auf den Slides an Produktsprache steht, ist woertlich aus der
|
||
Quelle: die Knotennamen und ihre Beschreibungen aus
|
||
assets/screens/workflow.png, die Bausteinliste aus
|
||
assets/screens/workflow-bausteine.png, die Artnamen aus
|
||
ui/components/NodeWorkflowEngine des Pitch-Pakets. Ueberschriften und
|
||
Fliesstexte sind Zuspitzung, aber sie behaupten nichts, was nicht in
|
||
einer dieser Quellen oder auf der Website steht.
|
||
|
||
Was hier bewusst NICHT steht: ein erfundener Vorgang. Ein frueherer Stand
|
||
hatte eine vierte Slide mit einem Aktivitaetenverlauf -- "Fr 16:41",
|
||
"Mo 07:12", "Bestaetigung an den Kunden gesendet". Nichts davon war
|
||
gemessen, alles war ausgedacht, und bei einem Produkt vor dem Start ist
|
||
eine erfundene Uhrzeit keine Veranschaulichung, sondern ein Beleg, den es
|
||
nicht gibt.
|
||
|
||
Zeilenlaengen geprueft: bei 76 px und -0,032em passen rund 26 Zeichen in
|
||
eine Zeile, die laengste hier hat 19. */
|
||
/* ─── Post 9 · Webinar ────────────────────────────────────────────────────
|
||
Die Einladung zum Webinar „Automatisierung im Handwerk.“ Zwei Elemente,
|
||
so wie es die Ad-Recherche vom 17.10. vorschlaegt
|
||
(HiveGrid Marketing/webinar/Ad-Recherche.md): zuerst das Bewegtbild mit
|
||
dem Titel, dann eine Slide mit Termin, Sprechern und Knopf. Als Anzeige
|
||
laufen beide hintereinander, die zweite ist dann das Endbild.
|
||
|
||
Die Texte stammen aus dem Webinar selbst: Titel und Untertitel von der
|
||
Titelslide, die vier Teile von der Slide „Was heute kommt“, die Fragerunde
|
||
von der Slide „Deine Fragen“. Termin, Uhrzeit und Dauer stehen in
|
||
WEBINAR weiter unten und sind noch Platzhalter.
|
||
|
||
Zeilenlaengen geprueft: bei 76 px passen rund 26 Zeichen in eine Zeile,
|
||
die laengste hier ist „in deinem Betrieb macht.“ mit 24. */
|
||
const VORLAGEN = [
|
||
{
|
||
/* Das Endbild. Die Ueberschrift nennt, was man nur live bekommt: die
|
||
Fragerunde am Schluss. Der Fliesstext ist der Untertitel des
|
||
Webinars, fast woertlich. Die Karte traegt den Termin, die Sprecher
|
||
und den Knopf. */
|
||
key: "anmeldung", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Jetzt anmelden",
|
||
head: 'Live zuschauen,<br><span class="a">live fragen.</span>',
|
||
body: "Was Automatisierung in deinem Betrieb wirklich macht. Mit dem Branchenprogramm, das du schon hast.",
|
||
vis: "anmeldung", visW: 896, visLeft: 92, visBottom: 168,
|
||
},
|
||
];
|
||
|
||
/* ═══ Animationen ════════════════════════════════════════════════════════
|
||
Wie in Post 7 und 8 ist das Bewegtbild das ERSTE Element im Carousel.
|
||
Frame 0 zeigt deshalb schon das fertige Webinar-Fenster mit Live-Marke,
|
||
erster Folie und beiden Gesichtern: Auf 150 px im Profilraster muss
|
||
ankommen, dass hier zwei Leute live etwas zeigen.
|
||
|
||
Die Ueberschrift ist der Titel des Webinars. Sie traegt allein, was sonst
|
||
die Titelslide getragen haette. */
|
||
const ANIMS = [
|
||
{
|
||
key: "animWebinar", type: "feature", dauer: 8,
|
||
eyebrow: "Live-Webinar",
|
||
head: 'Automatisierung<br><span class="a">im Handwerk.</span>',
|
||
/* Kein Fliesstext, wie in Post 7 und 8: die Titelslide traegt nur die
|
||
Ueberschrift, und die Karte rueckt mit nach oben. */
|
||
body: "",
|
||
/* 244 statt 226 wie in Post 8: die Karte ist hier niedriger, und der
|
||
Abstand zur Ueberschrift soll derselbe bleiben, rund 118 px. */
|
||
vis: "animWebinar", visW: 896, visLeft: 92, visBottom: 244,
|
||
},
|
||
];
|
||
|
||
|
||
/* 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;
|
||
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. */
|
||
/* Die Schatten des Produkts, aufgeloest aus ui/theme.css. Sie sind
|
||
gestaffelt und nicht einlagig -- genau daran hing es, dass die
|
||
Zeichnungen flach aussahen:
|
||
|
||
--shadow-sm 0 1px 2px rgb(0 0 0 / .08)
|
||
--shadow-md 0 4px 16px -2px + 0 2px 4px -1px
|
||
--shadow-lg 0 12px 32px -4px + 0 4px 8px -2px
|
||
|
||
Zwei Lagen, weil eine allein nicht beides kann: die enge dunkle Kante
|
||
setzt das Element auf die Flaeche, die weite helle hebt es davon ab. In
|
||
SVG heisst das zwei feDropShadow hintereinander im selben Filter.
|
||
|
||
Der negative Spread der CSS-Fassung (-2px, -4px) laesst sich in SVG nicht
|
||
nachbilden; er wird ueber eine etwas kleinere Streuung angenaehert. */
|
||
/* Wie wfSvg, aber mit Rand: der Inhalt wird um `rand` verschoben und der
|
||
Kasten entsprechend groesser. Damit liegt eine Flaeche, die bei -rand
|
||
beginnt, genau auf der Kante des Kastens -- und die Randmessung sieht
|
||
wieder, was wirklich dasteht. */
|
||
const svgMitRand = (b, h, rand, inhalt) =>
|
||
wfSvg(b + 2 * rand, h + 2 * rand,
|
||
'<g transform="translate(' + rand + ',' + rand + ')">' + inhalt + '</g>');
|
||
|
||
const SCHATTEN_DEFS =
|
||
'<defs>' +
|
||
'<filter id="hgklein" x="-40%" y="-40%" width="180%" height="180%">' +
|
||
'<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000000" flood-opacity="0.08"/>' +
|
||
'</filter>' +
|
||
'<filter id="hgschatten" x="-45%" y="-45%" width="190%" height="190%">' +
|
||
'<feDropShadow dx="0" dy="2" stdDeviation="1.6" flood-color="#000000" flood-opacity="0.08"/>' +
|
||
'<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.08"/>' +
|
||
'</filter>' +
|
||
'<filter id="hgweich" x="-10%" y="-10%" width="120%" height="120%">' +
|
||
'<feGaussianBlur stdDeviation="2.2"/>' +
|
||
'</filter>' +
|
||
'<filter id="hgtief" x="-45%" y="-45%" width="190%" height="190%">' +
|
||
'<feDropShadow dx="0" dy="4" stdDeviation="3" flood-color="#000000" flood-opacity="0.08"/>' +
|
||
'<feDropShadow dx="0" dy="12" stdDeviation="12" flood-color="#000000" flood-opacity="0.10"/>' +
|
||
'</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. */
|
||
|
||
/* ─── Der Zeichensatz ─────────────────────────────────────────────────────
|
||
Feather aus @subframe/core, dieselben Zeichen, mit denen die Oberflaeche
|
||
gebaut ist (rules.md 1a). Die ersten acht sind fuer die Workflow-Knoten
|
||
dazugekommen und aus dem Pitch-Paket gezogen; beide dort genannten Fallen
|
||
sind beachtet -- die Zahlen standen als x1={6} ohne Anfuehrungszeichen da,
|
||
und der <svg>-Rahmen muss weg, sonst rendert ein einkindriges Zeichen
|
||
nicht. */
|
||
|
||
/* ─── Symbole ────────────────────────────────────────────────────────────
|
||
Phosphor, nicht Feather. Der Feather-Satz steckt in so ziemlich jeder
|
||
Oberflaeche, die in den letzten Jahren entstanden ist -- man erkennt ihn
|
||
sofort wieder, und damit sieht eine Zeichnung aus wie jede andere. Am
|
||
deutlichsten war das am Gruppensymbol: zwei Koepfe nebeneinander, das
|
||
Piktogramm schlechthin.
|
||
|
||
Phosphor ist dieselbe Familie von Strichsymbolen, nur mit eigener
|
||
Handschrift: runder, gleichmaessiger, andere Metaphern (UsersThree statt
|
||
Users, PaperPlaneRight statt Send, CaretDown statt ChevronDown). Es ist
|
||
dasselbe Paket, das die Website benutzt (@phosphor-icons/react neben
|
||
lucide-react in ihrer package.json), also keine dritte Handschrift im
|
||
Haus.
|
||
|
||
Die Pfade sind direkt aus dem installierten Paket gezogen und nicht
|
||
abgeschrieben: Gewichtung "bold", Zeichenflaeche 256 x 256.
|
||
|
||
Die Gewichtung ist gemessen und nicht geschaetzt. Kein Farbwert hat sich
|
||
geaendert, aber ein Symbol aus duenneren Formen hat weniger voll gedeckte
|
||
Pixel und mehr angeschnittene -- es liest sich blasser, obwohl derselbe
|
||
Hexwert darinsteht. Im Kanalsymbol, Ausschnitt 44 x 44:
|
||
|
||
Feather, wie es vorher war 59 volle Pixel, Deckung 180
|
||
Phosphor regular 23 volle Pixel, Deckung 151 (16 % zu blass)
|
||
Phosphor bold 62 volle Pixel, Deckung 192 ( 6 % darueber)
|
||
|
||
Also bold. Ein Zwischenstand hatte regular, weil es "im Menue leichter
|
||
aussah" -- geschaetzt, nicht gemessen, und daneben.
|
||
|
||
Anders als bei Feather sind es gefuellte Formen und keine Striche, die
|
||
Strichdicke steckt also in der Form. ikon() faerbt deshalb ueber fill und
|
||
nicht ueber stroke, und der Parameter fuer die Staerke faellt weg. */
|
||
const IKON = {
|
||
"briefcase": '<path d="M100,100a12,12,0,0,1,12-12h32a12,12,0,0,1,0,24H112A12,12,0,0,1,100,100ZM236,68V196a20,20,0,0,1-20,20H40a20,20,0,0,1-20-20V68A20,20,0,0,1,40,48H76V40a28,28,0,0,1,28-28h48a28,28,0,0,1,28,28v8h36A20,20,0,0,1,236,68ZM100,48h56V40a4,4,0,0,0-4-4H104a4,4,0,0,0-4,4ZM44,72v35.23A180.06,180.06,0,0,0,128,128a180,180,0,0,0,84-20.78V72ZM212,192V133.94A204.27,204.27,0,0,1,128,152a204.21,204.21,0,0,1-84-18.06V192Z"/>',
|
||
"users": '<path d="M164.38,181.1a52,52,0,1,0-72.76,0,75.89,75.89,0,0,0-30,28.89,12,12,0,0,0,20.78,12,53,53,0,0,1,91.22,0,12,12,0,1,0,20.78-12A75.89,75.89,0,0,0,164.38,181.1ZM100,144a28,28,0,1,1,28,28A28,28,0,0,1,100,144Zm147.21,9.59a12,12,0,0,1-16.81-2.39c-8.33-11.09-19.85-19.59-29.33-21.64a12,12,0,0,1-1.82-22.91,20,20,0,1,0-24.78-28.3,12,12,0,1,1-21-11.6,44,44,0,1,1,73.28,48.35,92.18,92.18,0,0,1,22.85,21.69A12,12,0,0,1,247.21,153.59Zm-192.28-24c-9.48,2.05-21,10.55-29.33,21.65A12,12,0,0,1,6.41,136.79,92.37,92.37,0,0,1,29.26,115.1a44,44,0,1,1,73.28-48.35,12,12,0,1,1-21,11.6,20,20,0,1,0-24.78,28.3,12,12,0,0,1-1.82,22.91Z"/>',
|
||
"pin": '<path d="M238.15,78.54,177.46,17.86a20,20,0,0,0-28.3,0L97.2,70c-12.43-3.33-36.68-5.72-61.74,14.5a20,20,0,0,0-1.6,29.73l45.46,45.47-39.8,39.8a12,12,0,0,0,17,17l39.8-39.81,45.47,45.46A20,20,0,0,0,155.91,228c.46,0,.93,0,1.4-.05A20,20,0,0,0,171.87,220c4.69-6.23,11-16.13,14.44-28s3.45-22.88.16-33.4l51.7-51.87A20,20,0,0,0,238.15,78.54Zm-74.26,68.79a12,12,0,0,0-2.23,13.84c3.43,6.86,6.9,21-6.28,40.65L54.08,100.53c21.09-14.59,39.53-6.64,41-6a11.67,11.67,0,0,0,13.81-2.29l54.43-54.61,55,55Z"/>',
|
||
"file-text": '<path d="M216.49,79.52l-56-56A12,12,0,0,0,152,20H56A20,20,0,0,0,36,40V216a20,20,0,0,0,20,20H200a20,20,0,0,0,20-20V88A12,12,0,0,0,216.49,79.52ZM160,57l23,23H160ZM60,212V44h76V92a12,12,0,0,0,12,12h48V212Zm112-80a12,12,0,0,1-12,12H96a12,12,0,0,1,0-24h64A12,12,0,0,1,172,132Zm0,40a12,12,0,0,1-12,12H96a12,12,0,0,1,0-24h64A12,12,0,0,1,172,172Z"/>',
|
||
"image": '<path d="M144,96a16,16,0,1,1,16,16A16,16,0,0,1,144,96Zm92-40V200a20,20,0,0,1-20,20H40a20,20,0,0,1-20-20V56A20,20,0,0,1,40,36H216A20,20,0,0,1,236,56ZM44,60v79.72l33.86-33.86a20,20,0,0,1,28.28,0L147.31,147l17.18-17.17a20,20,0,0,1,28.28,0L212,149.09V60Zm0,136H162.34L92,125.66l-48,48Zm168,0V183l-33.37-33.37L164.28,164l32,32Z"/>',
|
||
"mic": '<path d="M128,180a52.06,52.06,0,0,0,52-52V64A52,52,0,0,0,76,64v64A52.06,52.06,0,0,0,128,180ZM100,64a28,28,0,0,1,56,0v64a28,28,0,0,1-56,0Zm40,155.22V240a12,12,0,0,1-24,0V219.22A92.14,92.14,0,0,1,36,128a12,12,0,0,1,24,0,68,68,0,0,0,136,0,12,12,0,0,1,24,0A92.14,92.14,0,0,1,140,219.22Z"/>',
|
||
/* Teilen statt Telefonhoerer. Der Eintrag heisst "Kontakt", und ein
|
||
Hoerer sagt "anrufen" -- hier wird eine Karte weitergegeben, nicht
|
||
telefoniert. ShareNetwork aus demselben Phosphor-bold-Satz; "phone"
|
||
stand danach nirgends mehr und ist mit heraus. */
|
||
"share-network": '<path d="M176,156a43.78,43.78,0,0,0-29.09,11L106.1,140.8a44.07,44.07,0,0,0,0-25.6L146.91,89a43.83,43.83,0,1,0-13-20.17L93.09,95a44,44,0,1,0,0,65.94L133.9,187.2A44,44,0,1,0,176,156Zm0-120a20,20,0,1,1-20,20A20,20,0,0,1,176,36ZM64,148a20,20,0,1,1,20-20A20,20,0,0,1,64,148Zm112,72a20,20,0,1,1,20-20A20,20,0,0,1,176,220Z"/>',
|
||
/* Fuer den Form Builder: Feldtypen, Griff, Werkzeuge, Hinweisfeld. */
|
||
"text-t": '<path d="M212,56V88a12,12,0,0,1-24,0V68H140V188h20a12,12,0,0,1,0,24H96a12,12,0,0,1,0-24h20V68H68V88a12,12,0,0,1-24,0V56A12,12,0,0,1,56,44H200A12,12,0,0,1,212,56Z"/>',
|
||
"text-align-left": '<path d="M28,64A12,12,0,0,1,40,52H216a12,12,0,0,1,0,24H40A12,12,0,0,1,28,64Zm12,52H168a12,12,0,0,0,0-24H40a12,12,0,0,0,0,24Zm176,16H40a12,12,0,0,0,0,24H216a12,12,0,0,0,0-24Zm-48,40H40a12,12,0,0,0,0,24H168a12,12,0,0,0,0-24Z"/>',
|
||
"list-bullets": '<path d="M76,64A12,12,0,0,1,88,52H216a12,12,0,0,1,0,24H88A12,12,0,0,1,76,64Zm140,52H88a12,12,0,0,0,0,24H216a12,12,0,0,0,0-24Zm0,64H88a12,12,0,0,0,0,24H216a12,12,0,0,0,0-24ZM44,112a16,16,0,1,0,16,16A16,16,0,0,0,44,112Zm0-64A16,16,0,1,0,60,64,16,16,0,0,0,44,48Zm0,128a16,16,0,1,0,16,16A16,16,0,0,0,44,176Z"/>',
|
||
"griff": '<path d="M108,60A16,16,0,1,1,92,44,16,16,0,0,1,108,60Zm56,16a16,16,0,1,0-16-16A16,16,0,0,0,164,76ZM92,112a16,16,0,1,0,16,16A16,16,0,0,0,92,112Zm72,0a16,16,0,1,0,16,16A16,16,0,0,0,164,112ZM92,180a16,16,0,1,0,16,16A16,16,0,0,0,92,180Zm72,0a16,16,0,1,0,16,16A16,16,0,0,0,164,180Z"/>',
|
||
"copy": '<path d="M216,28H88A12,12,0,0,0,76,40V76H40A12,12,0,0,0,28,88V216a12,12,0,0,0,12,12H168a12,12,0,0,0,12-12V180h36a12,12,0,0,0,12-12V40A12,12,0,0,0,216,28ZM156,204H52V100H156Zm48-48H180V88a12,12,0,0,0-12-12H100V52H204Z"/>',
|
||
"user-plus": '<path d="M256,136a12,12,0,0,1-12,12h-8v8a12,12,0,0,1-24,0v-8h-8a12,12,0,0,1,0-24h8v-8a12,12,0,0,1,24,0v8h8A12,12,0,0,1,256,136Zm-54.81,56.28a12,12,0,1,1-18.38,15.44C169.12,191.42,145,172,108,172c-28.89,0-55.46,12.68-74.81,35.72a12,12,0,0,1-18.38-15.44A124.08,124.08,0,0,1,63.5,156.53a72,72,0,1,1,89,0A124,124,0,0,1,201.19,192.28ZM108,148a48,48,0,1,0-48-48A48.05,48.05,0,0,0,108,148Z"/>',
|
||
"arrow-right": '<path d="M224.49,136.49l-72,72a12,12,0,0,1-17-17L187,140H40a12,12,0,0,1,0-24H187L135.51,64.48a12,12,0,0,1,17-17l72,72A12,12,0,0,1,224.49,136.49Z"/>',
|
||
"clock": '<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm68-84a12,12,0,0,1-12,12H128a12,12,0,0,1-12-12V72a12,12,0,0,1,24,0v44h44A12,12,0,0,1,196,128Z"/>',
|
||
"check-circle": '<path d="M176.49,95.51a12,12,0,0,1,0,17l-56,56a12,12,0,0,1-17,0l-24-24a12,12,0,1,1,17-17L112,143l47.51-47.52A12,12,0,0,1,176.49,95.51ZM236,128A108,108,0,1,1,128,20,108.12,108.12,0,0,1,236,128Zm-24,0a84,84,0,1,0-84,84A84.09,84.09,0,0,0,212,128Z"/>',
|
||
"caret-down": '<path d="M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"/>',
|
||
"asterisk": '<path d="M218.29,182.17a12,12,0,0,1-16.47,4.12L140,149.19V216a12,12,0,0,1-24,0V149.19l-61.82,37.1a12,12,0,1,1-12.35-20.58L104.68,128,41.83,90.29A12,12,0,1,1,54.18,69.71L116,106.81V40a12,12,0,0,1,24,0v66.81l61.82-37.1a12,12,0,1,1,12.35,20.58L151.32,128l62.85,37.71A12,12,0,0,1,218.29,182.17Z"/>',
|
||
"calendar": '<path d="M208,28H188V24a12,12,0,0,0-24,0v4H92V24a12,12,0,0,0-24,0v4H48A20,20,0,0,0,28,48V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V48A20,20,0,0,0,208,28ZM68,52a12,12,0,0,0,24,0h72a12,12,0,0,0,24,0h16V76H52V52ZM52,204V100H204V204Z"/>',
|
||
"square-check": '<path d="M79.51,144.49a12,12,0,1,1,17-17L112,143l47.51-47.52a12,12,0,0,1,17,17l-56,56a12,12,0,0,1-17,0ZM228,48V208a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V48A20,20,0,0,1,48,28H208A20,20,0,0,1,228,48Zm-24,4H52V204H204Z"/>',
|
||
"plus": '<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"/>',
|
||
"send": '<path d="M233.86,110.48,65.8,14.58A20,20,0,0,0,37.15,38.64L67.33,128,37.15,217.36A20,20,0,0,0,56,244a20.1,20.1,0,0,0,9.81-2.58l.09-.06,168-96.07a20,20,0,0,0,0-34.81ZM63.19,215.26,88.61,140H144a12,12,0,0,0,0-24H88.61L63.18,40.72l152.76,87.17Z"/>',
|
||
"message-square": '<path d="M120,128a16,16,0,1,1-16-16A16,16,0,0,1,120,128Zm32-16a16,16,0,1,0,16,16A16,16,0,0,0,152,112Zm84,16A108,108,0,0,1,78.77,224.15L46.34,235A20,20,0,0,1,21,209.66l10.81-32.43A108,108,0,1,1,236,128Zm-24,0A84,84,0,1,0,55.27,170.06a12,12,0,0,1,1,9.81l-9.93,29.79,29.79-9.93a12.1,12.1,0,0,1,3.8-.62,12,12,0,0,1,6,1.62A84,84,0,0,0,212,128Z"/>',
|
||
"chevron-down": '<path d="M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"/>',
|
||
"thumbs-up": '<path d="M237,77.47A28,28,0,0,0,216,68H164V56a44.05,44.05,0,0,0-44-44,12,12,0,0,0-10.73,6.63L72.58,92H32a20,20,0,0,0-20,20v88a20,20,0,0,0,20,20H204a28,28,0,0,0,27.78-24.53l12-96A28,28,0,0,0,237,77.47ZM36,116H68v80H36ZM220,96.5l-12,96a4,4,0,0,1-4,3.5H92V106.83L126.82,37.2A20,20,0,0,1,140,56V80a12,12,0,0,0,12,12h64a4,4,0,0,1,4,4.5Z"/>',
|
||
"corner-up-left": '<path d="M236,200a12,12,0,0,1-24,0,84.09,84.09,0,0,0-84-84H61l27.52,27.51a12,12,0,0,1-17,17l-48-48a12,12,0,0,1,0-17l48-48a12,12,0,0,1,17,17L61,92h67A108.12,108.12,0,0,1,236,200Z"/>',
|
||
"pencil": '<path d="M230.14,70.54,185.46,25.85a20,20,0,0,0-28.29,0L33.86,149.17A19.85,19.85,0,0,0,28,163.31V208a20,20,0,0,0,20,20H92.69a19.86,19.86,0,0,0,14.14-5.86L230.14,98.82a20,20,0,0,0,0-28.28ZM91,204H52V165l84-84,39,39ZM192,103,153,64l18.34-18.34,39,39Z"/>',
|
||
"trash-2": '<path d="M216,48H180V36A28,28,0,0,0,152,8H104A28,28,0,0,0,76,36V48H40a12,12,0,0,0,0,24h4V208a20,20,0,0,0,20,20H192a20,20,0,0,0,20-20V72h4a12,12,0,0,0,0-24ZM100,36a4,4,0,0,1,4-4h48a4,4,0,0,1,4,4V48H100Zm88,168H68V72H188ZM116,104v64a12,12,0,0,1-24,0V104a12,12,0,0,1,24,0Zm48,0v64a12,12,0,0,1-24,0V104a12,12,0,0,1,24,0Z"/>',
|
||
"hard-hat": '<path d="M228,148.4V136a100.41,100.41,0,0,0-64-93.3V40a20,20,0,0,0-20-20H112A20,20,0,0,0,92,40v2.7A100.41,100.41,0,0,0,28,136v12.4A20,20,0,0,0,12,168v24a20,20,0,0,0,20,20H224a20,20,0,0,0,20-20V168A20,20,0,0,0,228,148.4ZM204,136v12H164V69.07A76.35,76.35,0,0,1,204,136ZM140,44V148H116V44ZM92,69.07V148H52V136A76.35,76.35,0,0,1,92,69.07ZM220,188H36V172H220Z"/>',
|
||
"envelope": '<path d="M224,44H32A12,12,0,0,0,20,56V192a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V56A12,12,0,0,0,224,44ZM193.15,68,128,127.72,62.85,68ZM44,188V83.28l75.89,69.57a12,12,0,0,0,16.22,0L212,83.28V188Z"/>',
|
||
"funnel": '<path d="M234.29,47.91A20,20,0,0,0,216,36H40A20,20,0,0,0,25.2,69.45l.12.14L92,140.75V216a20,20,0,0,0,31.1,16.64l32-21.33A20,20,0,0,0,164,194.66V140.75l66.67-71.16.12-.14A20,20,0,0,0,234.29,47.91Zm-91,79.89A12,12,0,0,0,140,136v56.52l-24,16V136a12,12,0,0,0-3.25-8.2L49.23,60H206.77Z"/>',
|
||
"paper-plane": '<path d="M233.86,110.48,65.8,14.58A20,20,0,0,0,37.15,38.64L67.33,128,37.15,217.36A20,20,0,0,0,56,244a20.1,20.1,0,0,0,9.81-2.58l.09-.06,168-96.07a20,20,0,0,0,0-34.81ZM63.19,215.26,88.61,140H144a12,12,0,0,0,0-24H88.61L63.18,40.72l152.76,87.17Z"/>',
|
||
"cursor-click": '<path d="M224.15,179.17l-46.82-46.82,37.92-13.51c.26-.09.51-.19.76-.3a20,20,0,0,0-1.76-37.27L54.16,29A20,20,0,0,0,29,54.16L81.27,214.24A20,20,0,0,0,118.54,216c.11-.25.21-.5.3-.76l13.51-37.92,46.83,46.82a20,20,0,0,0,28.28,0l16.69-16.68A20,20,0,0,0,224.15,179.17Zm-30.83,25.17-48.48-48.48A20,20,0,0,0,130.7,150a20.47,20.47,0,0,0-3.73.35A20,20,0,0,0,112.35,162c-.11.25-.2.5-.3.76L100.4,195.5,54.29,54.29,195.5,100.4l-32.71,11.65c-.25.09-.51.19-.76.3a20,20,0,0,0-6.16,32.48h0l48.48,48.48ZM84,16V12a12,12,0,0,1,24,0v4a12,12,0,0,1-24,0ZM12,108a12,12,0,0,1,0-24h4a12,12,0,0,1,0,24ZM120.62,24.21l4-12a12,12,0,0,1,22.77,7.58l-4,12a12,12,0,0,1-22.77-7.58Zm-81.23,104a12,12,0,0,1-7.59,15.17l-12,4a12,12,0,1,1-7.59-22.76l12-4A12,12,0,0,1,39.39,128.21Z"/>',
|
||
"text-h-one": '<path d="M236,112v96a12,12,0,0,1-24,0V134.42L206.66,138a12,12,0,0,1-13.32-20l24-16A12,12,0,0,1,236,112ZM144,44a12,12,0,0,0-12,12v48H52V56a12,12,0,0,0-24,0V176a12,12,0,0,0,24,0V128h80v48a12,12,0,0,0,24,0V56A12,12,0,0,0,144,44Z"/>',
|
||
"squares-four": '<path d="M100,36H56A20,20,0,0,0,36,56v44a20,20,0,0,0,20,20h44a20,20,0,0,0,20-20V56A20,20,0,0,0,100,36ZM96,96H60V60H96ZM200,36H156a20,20,0,0,0-20,20v44a20,20,0,0,0,20,20h44a20,20,0,0,0,20-20V56A20,20,0,0,0,200,36Zm-4,60H160V60h36Zm-96,40H56a20,20,0,0,0-20,20v44a20,20,0,0,0,20,20h44a20,20,0,0,0,20-20V156A20,20,0,0,0,100,136Zm-4,60H60V160H96Zm104-60H156a20,20,0,0,0-20,20v44a20,20,0,0,0,20,20h44a20,20,0,0,0,20-20V156A20,20,0,0,0,200,136Zm-4,60H160V160h36Z"/>',
|
||
"minus": '<path d="M228,128a12,12,0,0,1-12,12H40a12,12,0,0,1,0-24H216A12,12,0,0,1,228,128Z"/>',
|
||
"check": '<path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"/>',
|
||
/* Fuer Post 9, das Webinar: Kamera, Termin, die vier Teile. Phosphor bold
|
||
wie alle anderen, aus @phosphor-icons/react der Website. */
|
||
"video-camera": '<path d="M249.45,69.31a12,12,0,0,0-12.51,1L212,88.43V72a20,20,0,0,0-20-20H32A20,20,0,0,0,12,72V184a20,20,0,0,0,20,20H192a20,20,0,0,0,20-20V167.57l24.94,18.14A12,12,0,0,0,256,176V80A12,12,0,0,0,249.45,69.31ZM188,180H36V76H188Zm44-27.57-20-14.54V118.11l20-14.54Z"/>',
|
||
"calendar-check": '<path d="M208,28H188V24a12,12,0,0,0-24,0v4H92V24a12,12,0,0,0-24,0v4H48A20,20,0,0,0,28,48V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V48A20,20,0,0,0,208,28ZM68,52a12,12,0,0,0,24,0h72a12,12,0,0,0,24,0h16V76H52V52ZM52,204V100H204V204Zm120.49-84.49a12,12,0,0,1,0,17l-48,48a12,12,0,0,1-17,0l-24-24a12,12,0,0,1,17-17L116,159l39.51-39.52A12,12,0,0,1,172.49,119.51Z"/>',
|
||
"kanban": '<path d="M216,44H40A12,12,0,0,0,28,56V208a20,20,0,0,0,20,20H88a20,20,0,0,0,20-20V164h40v12a20,20,0,0,0,20,20h40a20,20,0,0,0,20-20V56A12,12,0,0,0,216,44Zm-12,64H172V68h32ZM84,68v40H52V68Zm0,136H52V132H84Zm24-64V68h40v72Zm64,32V132h32v40Z"/>',
|
||
"tree-structure": '<path d="M160,116h48a20,20,0,0,0,20-20V48a20,20,0,0,0-20-20H160a20,20,0,0,0-20,20V60H128a28,28,0,0,0-28,28v28H76v-4A20,20,0,0,0,56,92H24A20,20,0,0,0,4,112v32a20,20,0,0,0,20,20H56a20,20,0,0,0,20-20v-4h24v28a28,28,0,0,0,28,28h12v12a20,20,0,0,0,20,20h48a20,20,0,0,0,20-20V160a20,20,0,0,0-20-20H160a20,20,0,0,0-20,20v12H128a4,4,0,0,1-4-4V88a4,4,0,0,1,4-4h12V96A20,20,0,0,0,160,116ZM52,140H28V116H52Zm112,24h40v40H164Zm0-112h40V92H164Z"/>',
|
||
"chats-circle": '<path d="M236.34,187.09A84,84,0,0,0,172.29,68.9,84,84,0,0,0,19.66,139.09l-6.84,23.26a20,20,0,0,0,24.83,24.83l23.26-6.84a83.94,83.94,0,0,0,22.76,6.74,84.06,84.06,0,0,0,111.42,41.26l23.26,6.84a20,20,0,0,0,24.83-24.83ZM62,155.5a11.88,11.88,0,0,0-3.39.49l-20.72,6.09L44,141.35a12,12,0,0,0-.93-9A60,60,0,1,1,67.7,156.92,12,12,0,0,0,62,155.5Zm150.89,24.8a12,12,0,0,0-.93,9l6.09,20.73L197.36,204a12,12,0,0,0-9.06.93A60,60,0,0,1,111,186.63a83.93,83.93,0,0,0,68.55-91.37,60,60,0,0,1,33.38,85Z"/>',
|
||
"gift": '<path d="M216,68H190.06A33.82,33.82,0,0,0,196,49.69,36.62,36.62,0,0,0,158.31,12,33.44,33.44,0,0,0,134,23.25a54.65,54.65,0,0,0-6,8.3,54.65,54.65,0,0,0-6-8.3A33.44,33.44,0,0,0,97.69,12,36.62,36.62,0,0,0,60,49.69,33.82,33.82,0,0,0,65.94,68H40A20,20,0,0,0,20,88v32a20,20,0,0,0,16,19.6V200a20,20,0,0,0,20,20H200a20,20,0,0,0,20-20V139.6A20,20,0,0,0,236,120V88A20,20,0,0,0,216,68Zm-4,48H140V92h72ZM152,39.17A9.59,9.59,0,0,1,159,36h.35A12.62,12.62,0,0,1,172,49,9.59,9.59,0,0,1,168.83,56c-6.9,6.12-18.25,9.26-27.63,10.76C142.7,57.42,145.84,46.07,152,39.17ZM87.7,39.7A12.8,12.8,0,0,1,96.61,36H97A9.59,9.59,0,0,1,104,39.17c6.12,6.9,9.26,18.24,10.75,27.61C105.45,65.27,94,62.13,87.17,56A9.59,9.59,0,0,1,84,49,12.72,12.72,0,0,1,87.7,39.7ZM44,92h72v24H44Zm16,48h56v56H60Zm80,56V140h56v56Z"/>',
|
||
};
|
||
|
||
function ikon(name, cx, cy, groesse, farbe) {
|
||
const s = groesse / 256;
|
||
return '<g transform="translate(' + n1(cx - groesse / 2) + ',' + n1(cy - groesse / 2) +
|
||
') scale(' + (Math.round(s * 100000) / 100000) + ')" fill="' + farbe + '">' +
|
||
(IKON[name] || "") + '</g>';
|
||
}
|
||
|
||
|
||
/* ═══ Die Bausteine des Workflow-Editors ═══════════════════════════════════
|
||
Nachbau aus ui/components/NodeWorkflowEngine/NodeWorkflowEngine.tsx des
|
||
Pitch-Pakets, so wie der Notizzettel in rules.md 6 aus StickyNote.tsx
|
||
nachgebaut ist. Der Grund ist derselbe: gezeichnet statt aufgenommen
|
||
bleibt beim 3-fach-Export so scharf wie die Schrift, laesst sich im Canvas
|
||
verschieben und traegt keinen Scrollbalken und keinen Cursor im Bild.
|
||
|
||
Zwei Entscheidungen, die dabei zu treffen waren:
|
||
|
||
RUNDE ECKEN, KEIN CHAMFER. Die Karten des Themes sind sonst chamfert, das
|
||
ist die Markenform (rules.md 3). Ein Workflow-Knoten ist aber kein Element
|
||
des Beitrags, sondern ein Zitat aus der Software, und dort hat er runde
|
||
Ecken. Dasselbe Verhaeltnis wie auf der Website: der Chamfer sitzt am
|
||
Rahmen um einen Screenshot, nicht im Screenshot. Wer die Knoten chamfert,
|
||
macht aus dem Zitat eine Nacherzaehlung.
|
||
|
||
MARKENFARBEN, NICHT UI-FARBEN. Im Produkt tragen die Knoten blau, gruen,
|
||
violett, rot und amber. Auf einer Slide waere das ein Regenbogen, und
|
||
rules.md 2 kennt diese Toene nicht -- beim Schaubild in post3.html ist
|
||
genau daran schon einmal Magenta aufgefallen. Hier traegt Teal die Arbeit,
|
||
Gelb den einen Akzent und Anthrazit die Entscheidung, wie es rules.md 1a
|
||
fuer das Theme modern festlegt. Die Struktur bleibt originalgetreu, die
|
||
Farbe folgt dem Haus. */
|
||
|
||
/* Die Farbe eines Knotens nach seiner Art. "auf" ist der Ton fuer Text auf
|
||
Weiss: reines Markengelb traegt dort bei kleinem Text nicht, deshalb der
|
||
abgedunkelte Wert aus rules.md 2. */
|
||
|
||
/* Ein gerundetes Rechteck als Pfad. Der erste Versuch legte die Kontur als
|
||
<rect rx="0"> ueber eine gerundete Flaeche -- an allen vier Ecken stand
|
||
dann ein Zipfel ueber die Rundung hinaus. Am gerenderten Blatt gesehen,
|
||
nicht vermutet. Kontur und farbige Kante teilen sich jetzt denselben
|
||
Pfad, und die Kante wird daran beschnitten statt danebengelegt. */
|
||
function rundRechteck(x, y, w, h, r) {
|
||
return "M" + n1(x + r) + "," + n1(y) +
|
||
"H" + n1(x + w - r) + "a" + r + "," + r + " 0 0 1 " + r + "," + r +
|
||
"V" + n1(y + h - r) + "a" + r + "," + r + " 0 0 1 " + (-r) + "," + r +
|
||
"H" + n1(x + r) + "a" + r + "," + r + " 0 0 1 " + (-r) + "," + (-r) +
|
||
"V" + n1(y + r) + "a" + r + "," + r + " 0 0 1 " + r + "," + (-r) + "Z";
|
||
}
|
||
|
||
/* Laufende Nummer fuer die Beschnitt-ids. Feste Namen gehen hier nicht: es
|
||
stehen mehrere Knoten in derselben Zeichnung, und jeder braucht seinen
|
||
eigenen Beschnitt an seiner eigenen Stelle. */
|
||
let wfNr = 0;
|
||
|
||
const wfEsc = t => String(t).replace(/[&<>]/g, c =>
|
||
({ "&": "&", "<": "<", ">": ">" }[c]));
|
||
|
||
/* Wie hoch ein Knoten wird. Gerechnet und nicht gesetzt, damit ein Knoten
|
||
mit Beschreibung und einer ohne nebeneinander sitzen koennen, ohne dass
|
||
jemand zwei Zahlen von Hand pflegt. */
|
||
|
||
/* Ein Knoten. Kopfzeile mit Zeichen und Eyebrow, Titel, darunter optional
|
||
der Beschreibungsblock hinter einer Haarlinie -- genau der Aufbau aus dem
|
||
Pitch-Screen. */
|
||
|
||
/* Ein Anschluss. Im Produkt ein kleiner Kreis auf der Kante, an dem die
|
||
Leitung haengt. Er ist der Grund, warum die Linien nicht einfach an der
|
||
Kartenkante aufhoeren, sondern sichtbar andocken. */
|
||
|
||
/* Eine Leitung zwischen zwei Anschluessen. Rechtwinklig mit gerundeter Ecke,
|
||
wie im Editor: eine frei geschwungene Kurve saehe nach Zeichnung aus, und
|
||
hier wird ein Werkzeug zitiert, keine Skizze.
|
||
|
||
Die Beschriftung sitzt als Plaettchen auf der Bahn ("Ja" / "Nein"), genau
|
||
wie TRUE und FALSE im Pitch-Screen. */
|
||
|
||
/* Ein Kaestchen fuer die Uebersicht auf Slide 5: nur Zeichen und Wort. Der
|
||
ganze Knoten waere dort sechsmal nebeneinander nicht mehr lesbar, und die
|
||
Slide soll die Form des Ablaufs zeigen, nicht seinen Inhalt. */
|
||
|
||
const wfSvg = (w, h, inhalt) =>
|
||
'<svg viewBox="0 0 ' + w + " " + h + '" width="' + w + '" height="' + h +
|
||
'" xmlns="http://www.w3.org/2000/svg">' + SCHATTEN_DEFS + inhalt + "</svg>";
|
||
|
||
|
||
|
||
|
||
|
||
/* ─── Slide 5 · Der ganze Ablauf ─────────────────────────────────────────
|
||
Dieselben sechs Schritte wie in assets/screens/workflow.png, als Form.
|
||
Volle Slide-Breite und links wie rechts angeschnitten: der Ablauf ist
|
||
laenger als das Bild, und genau das soll man sehen. */
|
||
|
||
|
||
/* ═══ Oberflaechen-Bausteine ═══════════════════════════════════════════════
|
||
Ein Workflow-Canvas ist nicht die einzige Ansicht, die es von der Engine
|
||
gibt, und sechs Slides mit demselben Bild sind ein Muster und keine
|
||
Dramaturgie. Die Bausteine hier bauen die uebrigen Ansichten nach, die im
|
||
Pitch-Paket stecken: die Aktivitaetenspalte aus dem Karten-Detail
|
||
(02_Praesentation/07), die Bausteinauswahl aus NodeWorkflowEngine.Katalog
|
||
und die Feldliste aus derselben Detailansicht.
|
||
|
||
Damit traegt jede Slide eine eigene Sorte Oberflaeche: Verlauf, Auswahl,
|
||
Formular, Verzweigung, Uebersicht, Vorlagen. Dieselbe Software, sechs
|
||
verschiedene Blicke darauf. */
|
||
|
||
/* Das Panel, auf dem alles liegt. Wie die Karte des Themes, aber mit den
|
||
runden Ecken des Produkts -- derselbe Grund wie beim Knoten: das ist ein
|
||
Zitat aus der Oberflaeche, nicht ein Element des Beitrags. */
|
||
function uiPanel(x, y, w, h, o) {
|
||
o = o || {};
|
||
const k = rundRechteck(x, y, w, h, 18);
|
||
return '<g filter="url(#hgschatten)">' +
|
||
'<path d="' + k + '" fill="' + (o.grund || KARTE) + '"/>' +
|
||
'<path d="' + k + '" fill="none" stroke="' + KANTE + '" stroke-width="1.4"/></g>';
|
||
}
|
||
|
||
/* Eine Kachel mit Zeichen, wie sie im Produkt vor jeder Zeile steht. */
|
||
function uiKachel(x, y, s, zeichen, farbe, grund) {
|
||
return '<rect x="' + n1(x) + '" y="' + n1(y) + '" width="' + s + '" height="' + s +
|
||
'" rx="' + n1(s * 0.26) + '" fill="' + grund + '"/>' +
|
||
ikon(zeichen, x + s / 2, y + s / 2, s * 0.52, farbe);
|
||
}
|
||
|
||
const uiText = (x, y, t, gr, gew, farbe, o) => {
|
||
o = o || {};
|
||
return '<text x="' + n1(x) + '" y="' + n1(y) + '" font-family="Manrope, sans-serif" ' +
|
||
'font-size="' + gr + '" font-weight="' + gew + '" fill="' + farbe + '"' +
|
||
(o.anker ? ' text-anchor="' + o.anker + '"' : "") +
|
||
(o.sperr ? ' letter-spacing="' + o.sperr + '"' : "") +
|
||
(o.kursiv ? ' font-style="italic"' : "") + ">" + wfEsc(t) + "</text>";
|
||
};
|
||
|
||
/* Die Ueberschrift eines Abschnitts: klein, gesperrt, grau. So stehen die
|
||
Rubriken in der Detailansicht -- GRUNDDATEN, PLANUNG & VERANTWORTLICHE,
|
||
PROJEKTADRESSE.
|
||
|
||
Hier stand vorher "KUNDENDATEN und AUFTRAG & FOERDERUNG". Beides kommt in
|
||
`S02PipelinesDetailansicht.tsx` nicht vor; es war aus einem Entwurfs-PNG
|
||
abgeschrieben. Der Bildschirm dazu ist echt, nur anders beschriftet. */
|
||
const uiRubrik = (x, y, t) => uiText(x, y, String(t).toUpperCase(), 15, 800, TEXT3,
|
||
{ sperr: "0.13em" });
|
||
|
||
|
||
|
||
|
||
|
||
/* Sechs Ansichten, sechs Sorten Oberflaeche. Dass hier nicht sechsmal
|
||
dasselbe Canvas steht, ist der Punkt: Verlauf, Auswahl, Formular,
|
||
Verzweigung, Uebersicht, Vorlagen. */
|
||
|
||
/* ═══ Die Farben des Produkts ══════════════════════════════════════════════
|
||
Aufgeloest aus ui/theme.css des Pitch-Pakets, ueber die ganze Kette:
|
||
surface-ui-subtle-green-default -> --color-ui-green-100 -> --color-green-100
|
||
Nicht aus dem Bild geschaetzt, sondern nachgerechnet.
|
||
|
||
Ein frueherer Stand hat sie durch Markenfarben ersetzt, weil rules.md 2
|
||
diese Toene nicht kennt. Das ist hier bewusst zurueckgenommen: der Beitrag
|
||
zeigt die Oberflaeche, und ihre Farbe traegt Bedeutung -- gruen faengt an,
|
||
rot arbeitet an Daten, violett entscheidet, blau meldet, gelb schliesst
|
||
ab. Umgefaerbt waere es eine Nacherzaehlung.
|
||
|
||
Der Rand ist border-default #E5E5E5, also der Wert, vor dem rules.md 5
|
||
warnt ("verschwindet auf Weiss"). Hier traegt er trotzdem: die Felder sind
|
||
farbig gefuellt, der Rand muss keine weisse Flaeche halten. */
|
||
const UI = {
|
||
gruen: { flaeche: "#DCFCE4", zeichen: "#148134" },
|
||
rot: { flaeche: "#FEE2E2", zeichen: "#C52020" },
|
||
violett: { flaeche: "#F0E7FF", zeichen: "#691CD4" },
|
||
blau: { flaeche: "#D5EFFF", zeichen: "#003BEB" },
|
||
gelb: { flaeche: "#FCF6C5", zeichen: "#9B5E0D" },
|
||
grau: { flaeche: "#FAFAFA", zeichen: "#858589" },
|
||
};
|
||
const UI_RAND = "#E5E5E5";
|
||
|
||
/* ═══ Der Notizzettel ══════════════════════════════════════════════════════
|
||
Nachbau von ui/components/StickyNote/StickyNote.tsx. Die Werte stehen dort
|
||
in den Tailwind-Klassen: rounded-md, shadow-md, Kopfleiste h-8 mit
|
||
rounded-t-md, Text #424242. Die sechs Farbpaare ebenso -- Kopf und Flaeche
|
||
getrennt, nicht abgeleitet.
|
||
|
||
rules.md 6 beschreibt denselben Baustein fuer Post 1, dort in der Variante
|
||
purple. Hier tragen die Zettel die Farben, die der Ablauf im Produkt
|
||
tatsaechlich hat.
|
||
|
||
Warum ueberhaupt: die Zettel sind das, was einen Ablauf im Werkzeug von
|
||
einem Diagramm unterscheidet. Jemand hat danebengeschrieben, was der
|
||
Schritt soll. Das gehoert ins Bild. */
|
||
|
||
|
||
/* ═══ Der Knoten, wie ihn das Produkt rendert ══════════════════════════════
|
||
Nachbau von NodeItem aus ui/components/NodeWorkflowEngine/NodeWorkflowEngine.tsx
|
||
im Pitch-Paket. Die Werte stehen dort woertlich in den Tailwind-Klassen:
|
||
|
||
h-32 w-32 quadratisch, 128 x 128
|
||
rounded-lg 8 px Eckradius
|
||
border-[6px] 6 px Rand in border-default
|
||
flex-col items-center gap-1.5
|
||
Titel und Beschreibung stehen UNTER dem Feld,
|
||
zentriert -- nicht daneben und nicht darin
|
||
rounded-s-round beim Ausloeser ist die linke Seite voll gerundet
|
||
|
||
Das ist auch genau das, was assets/screens/workflow.png zeigt: dieselbe
|
||
Komponente, gerendert.
|
||
|
||
WAS HIER VORHER STAND, WAR FALSCH. Ein frueherer Stand zeichnete breite
|
||
Karten mit farbiger Kante links, einem Eyebrow und einem Block
|
||
"BESCHREIBUNG" hinter einer Haarlinie -- abgemalt vom Pitch-Bild
|
||
02_Praesentation/03. Im Code des Projekts gibt es das nirgends: weder
|
||
"BESCHREIBUNG" als Label noch eine linke Farbkante, beides null Treffer.
|
||
Der Kommentar behauptete trotzdem, es sei ein Nachbau der Komponente.
|
||
|
||
Geaendert sind nur die Farben, wie abgesprochen: das Produkt faerbt die
|
||
Felder blau, gruen, violett, rot und amber, und rules.md 2 kennt diese
|
||
Toene nicht. Gelb traegt den Ausloeser, Teal die Aktion, Anthrazit die
|
||
Entscheidung. Form und Aufbau bleiben. */
|
||
|
||
/* Die Farbe steht am Knoten, nicht an seiner Art: im Produkt ist sie eine
|
||
Eigenschaft des Bausteins, und derselbe Ablauf hat rote und blaue
|
||
Aktionen nebeneinander. Woertlich so in workflow.png und im Preset. */
|
||
|
||
/* Wie hoch ein Knoten samt Beschriftung wird. Gerechnet, damit Zeilen
|
||
verschiedener Laenge nebeneinander sauber stehen. */
|
||
|
||
/* x, y bezeichnen die linke obere Ecke des Feldes, nicht des Textblocks. */
|
||
|
||
|
||
/* ─── Slide 2 und 3 · Die Bausteinauswahl ────────────────────────────────
|
||
Nachgebaut aus assets/screens/workflow-bausteine.png. Jede Zeile steht
|
||
dort woertlich so, Rubrik und Beschreibungen eingeschlossen.
|
||
|
||
ZWEI SLIDES AUS EINER FUNKTION, weil es im Produkt eine Auswahl ist: die
|
||
Rubriken "Auslöser" und "Aktionen" stehen dort untereinander im selben
|
||
Feld. Getrennt gezeichnet waeren es zwei Bilder, die zufaellig gleich
|
||
aussehen; so ist es sichtbar dieselbe Liste, einmal oben und einmal
|
||
unten.
|
||
|
||
DIE FARBE KOMMT AUS DER KATEGORIE, nicht aus dem Knotentyp. Im Screenshot
|
||
tragen alle fuenf Ausloeser dieselbe gelbe Kachel und die Aktion eine
|
||
blaue, samt Punkt vor der Rubrik. Auf dem Canvas ist es anders -- dort
|
||
faerbt die Wurzelkomponente je Art (amber fuer Aufgaben, blau fuer
|
||
Benachrichtigungen, rot fuer Daten). Beides stimmt, es sind zwei
|
||
verschiedene Ansichten, und die Auswahl folgt hier der Auswahl. */
|
||
|
||
|
||
|
||
|
||
/* ─── Slide 1 · Der Ablauf ───────────────────────────────────────────────
|
||
Das Titelbild. Im Profilraster ist es eine Kachel von rund 150 px auf 3:4
|
||
beschnitten -- dort ist kein Wort lesbar, und was ankommt, ist die Form:
|
||
eine Kette, die sich gabelt.
|
||
|
||
WARUM DIE GABEL FRUEH KOMMT. Ein frueherer Stand zeigte den Stamm aus
|
||
drei Knoten und danach je einen Zweig. Das ist die Reihenfolge des
|
||
Screenshots, fuellt aber ein hochkantes Format schlecht: solange der
|
||
Stamm laeuft, steht ueber und unter ihm nichts, und das waren drei von
|
||
vier Spalten in zwei von drei Reihen. Jetzt gabelt es nach dem zweiten
|
||
Knoten, und beide Zweige sind zwei tief -- ab Spalte drei ist oben wie
|
||
unten belegt.
|
||
|
||
DAFUER FEHLT EIN SCHRITT, und das gehoert gesagt: zwischen "Formular
|
||
eingegangen" und "Passender Lead?" steht in workflow.png noch "Daten
|
||
vereinheitlichen". Alle sechs gezeigten Knoten heissen woertlich so wie
|
||
dort, ihre Farben stimmen ebenso, aber diese eine Kante ist
|
||
zusammengezogen. Ein Ablauf, den es genau so gibt, ist es damit nicht
|
||
mehr -- einer, den es geben koennte, schon.
|
||
|
||
Die Anordnung selbst ist ohnehin meine: im Werkzeug zieht man Knoten mit
|
||
der Maus, die Koordinaten im Preset sind die eines Bildschirms im
|
||
Querformat. */
|
||
|
||
/* ─── Slide 3 · Die Vorlagen ─────────────────────────────────────────────
|
||
Fertige Ablaeufe zum Uebernehmen. Namen, Beschreibungen und Startbausteine
|
||
woertlich aus GENERAL_WORKFLOW_PRESETS in WorkflowEngineClickDummy.tsx.
|
||
|
||
WARUM GENAU DIESE DREI. Es gibt sechs Vorlagen, aber drei davon nennen
|
||
PDS -- zwei im Namen, eine in der Beschreibung. PDS gehoert hier nicht
|
||
hin: der Beitrag richtet sich an Betriebe allgemein, nicht an die, die
|
||
zufaellig dieses eine Branchenprogramm fahren. Uebrig bleibt genau die
|
||
Gruppe GENERAL_WORKFLOW_PRESETS, in der das Wort kein einziges Mal
|
||
vorkommt. Das ist keine Auswahl nach Geschmack, sondern die vollstaendige
|
||
zweite Gruppe.
|
||
|
||
Gekuerzt wird nichts. Aus "Offene Angebote aus PDS erkennen" ein "Offene
|
||
Angebote erkennen" zu machen waere die bequeme Loesung und kein Zitat
|
||
mehr -- lieber eine Karte weniger.
|
||
|
||
DIE KARTENFORM IST MEINE. Im Werkzeug werden Vorlagen nur geladen, nicht
|
||
als Galerie gezeigt; es gibt dort keine Ansicht, an die ich mich halten
|
||
koennte. Woertlich ist der Text, gebaut ist die Form.
|
||
|
||
Die Kachel links traegt das Zeichen des Bausteins, mit dem die Vorlage
|
||
startet, und ist gruen: im Farbsystem des Produkts sind alle Ausloeser
|
||
gruen, und jede Vorlage faengt mit einem an. */
|
||
|
||
|
||
/* ═══ Das Board ════════════════════════════════════════════════════════════
|
||
Nachbau der Pipeline aus assets/screens/pipelines.png. Die Phasennamen,
|
||
die Projekte und ihre Werte stehen woertlich in
|
||
components/subframe-pages/PipelineBoardInteractive.tsx des Pitch-Pakets
|
||
(STAGES und INITIAL_PROJECTS) -- der Screenshot im Repo ist ein Render
|
||
derselben Daten, die Namen decken sich.
|
||
|
||
Die Toene der Plaettchen kommen aus ui/components/Tag_Label des
|
||
Pitch-Pakets, Flaeche und Rand getrennt, und die Zuordnung Kategorie zu
|
||
Ton aus CATEGORY_TONES. Anders als in den Beitraegen davor wird hier
|
||
nichts umgefaerbt: eine Pipeline lebt davon, dass man die Kategorie an
|
||
der Farbe erkennt, ohne zu lesen. */
|
||
const TON = {
|
||
gelb: { flaeche: "#fcf6c5", rand: "#faeb8e" },
|
||
blau: { flaeche: "#d5efff", rand: "#b3dfff" },
|
||
orange: { flaeche: "#ffe7d4", rand: "#ffcaa8" },
|
||
teal: { flaeche: "#c8fff7", rand: "#90ffee" },
|
||
gruen: { flaeche: "#dcfce4", rand: "#baf8cb" },
|
||
violett: { flaeche: "#f0e7ff", rand: "#e4d4ff" },
|
||
rot: { flaeche: "#fee2e2", rand: "#fdcbcb" },
|
||
himmel: { flaeche: "#e1f4fd", rand: "#bceafb" },
|
||
grau: { flaeche: "#FAFAFA", rand: "#E5E5E5" },
|
||
};
|
||
/* CATEGORY_TONES aus PipelineBoardInteractive.tsx, eins zu eins. */
|
||
/* Die Farbe des Kuerzels haengt am Menschen, nicht am Projekt: avatarColor
|
||
steht je Projekt in INITIAL_PROJECTS, und Avatar.tsx greift damit auf
|
||
dieselben Marken zu wie alles andere (surface-ui-subtle-<farbe> und
|
||
text-ui-<farbe>). Wer auf mehreren Karten steht, hat auf allen dieselbe
|
||
Farbe -- deshalb ist es eine Zuordnung und keine Laune je Karte. */
|
||
const AVATAR = {
|
||
violett: { flaeche: "#F0E7FF", text: "#691CD4" },
|
||
gelb: { flaeche: "#FCF6C5", text: "#9B5E0D" },
|
||
blau: { flaeche: "#D5EFFF", text: "#003BEB" },
|
||
gruen: { flaeche: "#DCFCE4", text: "#148134" },
|
||
};
|
||
|
||
const PM_TEXT = "#24242B", PM_TEXT2 = "#505057", PM_TEXT3 = "#858589";
|
||
|
||
|
||
/* Ein Plaettchen: Flaeche, Rand, dunkler Text. Die Breite wird aus der
|
||
Zeichenzahl geschaetzt -- SVG kann nicht umbrechen, und ein zu schmales
|
||
Plaettchen schneidet das Wort ab. 0,52 em ist die mittlere Vorschubbreite
|
||
von Manrope im Schnitt 700. */
|
||
|
||
|
||
/* SVG bricht Text nicht um. "Heizung Mehrfamilienhaus Wagner" stand
|
||
deshalb quer ueber den Kartenrand hinaus. Im Werkzeug bricht der Titel
|
||
auf zwei Zeilen (line-clamp-2) -- also hier auch, nach Woertern und
|
||
gemessen. */
|
||
|
||
/* Eine Projektkarte, nachgebaut aus ui/components/ProjectCard/ProjectCard.tsx
|
||
des Pitch-Pakets. Der erste Anlauf war vom Screenshot abgemalt und lag an
|
||
drei Stellen daneben, die im Bild nicht zu erkennen waren:
|
||
|
||
- Prioritaet, Balken und Kuerzel stehen zusammen rechts und haengen ueber
|
||
die Kante des Fusses (absolute -bottom-4 right-2), nicht links und
|
||
rechts verteilt. Der Fuss hat dafuer oben eine tiefe Zugabe (pt-6).
|
||
- Im Fuss steht ueber dem Termin die Zeitspanne des Projekts, mit einem
|
||
Start- und einem Zielsymbol und einer Linie dazwischen. Die hatte ich
|
||
ganz uebersehen.
|
||
- Das Kuerzel traegt einen Ring in der Farbe der Prioritaet, und der
|
||
Terminstreifen ist voll rund und mittig gesetzt.
|
||
|
||
Der Titel bricht auf hoechstens zwei Zeilen (line-clamp-2), die Zeile mit
|
||
Kunde und Ort auf eine (line-clamp-1) -- das ist keine Auslegung, das
|
||
steht so in den Klassen. Wer keine Zeitspanne hat, bekommt die Zeile
|
||
nicht: hideDateRange. Genau so ein Fall ist bei den gezeigten Projekten
|
||
dabei. */
|
||
/* b: 320 ist w-80, die Breite der echten Karte. Der Betrag ist von 30 auf
|
||
26 herunter, weil er sonst an die Prioritaetsgruppe stiess -- bei
|
||
"Niedrig" ist deren Plaettchen breiter als bei "Hoch", und genau dort
|
||
ueberlappte es. */
|
||
|
||
|
||
/* Alle senkrechten Marken einer Karte an einer Stelle gerechnet. Hoehe und
|
||
Zeichnung liefen vorher getrennt und mussten von Hand gleich gehalten
|
||
werden -- beim ersten Umbau war genau das schiefgegangen. */
|
||
|
||
|
||
/* Der Spaltenkopf: Name, Anzahl, Summe. Die Summe ist das, was ein Board
|
||
einem Chef sagt und eine Liste nicht -- wie viel in dieser Phase steht. */
|
||
|
||
/* Die Projekte. Titel, Kunde, Ort, Kategorie, Kundenart, Betrag, Prioritaet,
|
||
Kuerzel und Termin stehen woertlich so in INITIAL_PROJECTS. Gerechnet sind
|
||
nur die Spaltensummen -- aus denselben Daten, nicht geschaetzt. */
|
||
|
||
/* ─── Slide 1 · Das Board ────────────────────────────────────────────────
|
||
Drei Spalten mit je drei Karten. Im Profilraster ist die Slide eine Kachel
|
||
von rund 150 px -- dort ist kein Wort lesbar, und was ankommt, sind Saeulen
|
||
aus Kaertchen. Genau daran erkennt jemand ein Board, bevor er etwas
|
||
gelesen hat.
|
||
|
||
Oben die Ansichtsumschaltung aus dem Screenshot. Sie kostet wenig Platz
|
||
und sagt nebenbei, dass dieselben Daten auch als Liste dastehen. */
|
||
|
||
/* ─── Slide 2 · Die Karte ────────────────────────────────────────────────
|
||
Eine Karte, gross. Slide 1 zeigt das ganze Board, hier kann man lesen,
|
||
was auf einer steht.
|
||
|
||
Zwei standen hier vorher nebeneinander, um Hoch und Niedrig zu
|
||
vergleichen. Das teilte den Blick: gefragt ist eine Karte, nicht ein
|
||
Vergleich. Die Farbe des Fusses erklaert sich auf Slide 1 von selbst,
|
||
dort stehen neun Karten in drei Toenen nebeneinander. */
|
||
|
||
|
||
|
||
|
||
/* ─── Slide 3 · Die Detailansicht ────────────────────────────────────────
|
||
Screen 4B des Demo-Projekts, `S02PipelinesDetailansicht.tsx`.
|
||
|
||
Das Wichtigste an dieser Slide steht in der Kopfzeile: das Projekt liegt
|
||
in der Pipeline "Auftragsabwicklung SHK", nicht in "Vertrieb:
|
||
Anlagen-Verkauf", wo das Board von Slide 1 zu Hause ist. Es sind zwei
|
||
Pipelines, und
|
||
der Knopf "Pipeline" daneben schiebt ein Projekt von der einen in die
|
||
andere. Verkauft ist eben nicht fertig.
|
||
|
||
Die Rubriken sind die echten. Zwei fehlen mit Absicht:
|
||
"PDS-INTEGRATION", weil der Beitrag sich an Betriebe allgemein richtet
|
||
und nicht an die, die zufaellig dieses eine Branchenprogramm fahren
|
||
(dieselbe Entscheidung wie bei Post 4), und "ADRESSDATEN · TEST A
|
||
(ZUSATZADRESSEN AN)", weil das ein Arbeitstitel aus der Entwicklung ist
|
||
und kein Bildschirmtext. */
|
||
|
||
|
||
/* ─── Slide 4 · Die Liste ────────────────────────────────────────────────
|
||
Screen 4C, `S03ProjektListe.tsx`. Dieselben Projekte, andere Ansicht --
|
||
eine Zeile je Projekt statt einer Karte.
|
||
|
||
Die Tabelle im Werkzeug hat neun Spalten: Projekt-Nr., Projektname,
|
||
Board, Spalte, Auftragswert, Kunde / Kontaktrolle, Termin / Hinweis,
|
||
Tags, Sachbearbeiter. Fuenf davon stehen hier. Neun nebeneinander waeren
|
||
auf 1080 px unter zehn Pixel Schrift, und dann zeigt die Slide zwar die
|
||
ganze Tabelle, aber niemand liest sie. Weggelassen ist nichts
|
||
Ausgedachtes, nur Vorhandenes.
|
||
|
||
"Auftragswert gesperrt" mit dem Schloss ist echt und bleibt drin: nicht
|
||
jeder darf jede Zahl sehen, und das ist in einem Betrieb keine
|
||
Nebensache. */
|
||
|
||
|
||
|
||
/* ─── Slide 5 · Die Schablone ────────────────────────────────────────────
|
||
Screen 3, `S05DetailSchabloneEditMode.tsx`. Hier wird festgelegt, welche
|
||
Felder eine Pipeline traegt.
|
||
|
||
Das ist die Antwort auf die Frage, wie eine Software Verkauf und
|
||
Abwicklung zugleich kann, ohne fuer beides dasselbe Formular
|
||
hinzustellen: die Felder haengen an der Pipeline, nicht am Programm. Ein
|
||
SHK-Betrieb braucht "Aufmass erledigt", ein anderer etwas anderes.
|
||
|
||
"Aufmass erledigt" ist das Feld, das im Screen offen ist. Die Feldtypen
|
||
und die drei Stufen unter "Pflicht ab Board-Spalte" stehen woertlich so
|
||
in der Datei. */
|
||
|
||
|
||
/* Breiten messen statt schaetzen. Der Vorgaenger hat sie aus der
|
||
Zeichenzahl mal 0,52 em gerechnet -- das stimmt fuer "Heizung" und geht
|
||
bei "Waermepumpe" daneben, weil breite Zeichen und Umlaute mehr Vorschub
|
||
brauchen als der Durchschnitt. Ein Canvas misst dieselbe Schrift, die das
|
||
SVG spaeter zeichnet, solange Manrope geladen ist -- darauf wartet die
|
||
Seite ohnehin. Der Kontext wird einmal angelegt und behalten. */
|
||
const messen = (() => {
|
||
let ctx = null;
|
||
const zwischen = new Map();
|
||
return (text, gr, schnitt) => {
|
||
const schluessel = schnitt + "|" + gr + "|" + text;
|
||
if (zwischen.has(schluessel)) return zwischen.get(schluessel);
|
||
if (!ctx) ctx = document.createElement("canvas").getContext("2d");
|
||
ctx.font = schnitt + " " + gr + "px Manrope, sans-serif";
|
||
const b = ctx.measureText(text).width;
|
||
zwischen.set(schluessel, b);
|
||
return b;
|
||
};
|
||
})();
|
||
|
||
/* SVG bricht Text nicht um. Fuer einen Chat ist das die zentrale Frage:
|
||
eine Nachricht ist ein Absatz, keine Zeile. Umgebrochen wird nach
|
||
Woertern und gemessen. */
|
||
function umbrechen(text, maxB, gr, schnitt, maxZeilen) {
|
||
const zeilen = [];
|
||
let z = "";
|
||
for (const w of String(text).split(" ")) {
|
||
const probe = z ? z + " " + w : w;
|
||
if (z && messen(probe, gr, schnitt) > maxB) { zeilen.push(z); z = w; }
|
||
else z = probe;
|
||
}
|
||
if (z) zeilen.push(z);
|
||
if (!maxZeilen || zeilen.length <= maxZeilen) return zeilen;
|
||
const rest = zeilen.slice(maxZeilen - 1).join(" ");
|
||
let k = rest;
|
||
while (k.length > 1 && messen(k + "…", gr, schnitt) > maxB) k = k.slice(0, -1);
|
||
return zeilen.slice(0, maxZeilen - 1).concat(k + "…");
|
||
}
|
||
|
||
/* ═══ Der Team-Chat ════════════════════════════════════════════════════════
|
||
Nachbau von components/general/GeneralTeamChatPage.tsx aus dem Pitch-Paket
|
||
(fertig bombo, Kundentermin 30.07.2026).
|
||
|
||
Anders als bei der Pipeline gibt es vom Chat nur diese eine Fassung, unter
|
||
/allgemein/team-chat. Eine eigene SHK-Variante existiert nicht -- die
|
||
Inhalte darin sind aber ohnehin aus dem Gewerk: Waermepumpe Mueller,
|
||
Montagetermin, Baustellenfotos.
|
||
|
||
Alle Namen, Zeiten, Vorschautexte und Anhaenge stehen woertlich in CHATS
|
||
und INITIAL_MESSAGES. Erfunden ist nichts. */
|
||
const CHAT_TEXT = "#24242B", CHAT_TEXT2 = "#505057", CHAT_TEXT3 = "#858589";
|
||
/* Die drei Flaechen des Werkzeugs, aufgeloest aus ui/theme.css:
|
||
surface-neutral-primary grey-50 #F5F4F4 der Grund der Anwendung
|
||
surface-neutral-tertiary grey-100 #F0F0F0 die Unterhaltungsspalte
|
||
surface-neutral-secondary grey-0 #FAFAFA Karten und Blasen
|
||
|
||
Auf den Slides werden sie nicht als Hintergrund gemalt -- die
|
||
Zeichnungen bleiben durchsichtig und stehen auf dem Punktgrund der
|
||
Slide. Gebraucht werden die Werte dort, wo eine Flaeche wirklich eine
|
||
ist: im Schirm des Geraets auf Slide 5.
|
||
|
||
Karten und Blasen stehen auf reinem Weiss statt auf #FAFAFA. Auf dem
|
||
hellen Blatt der Slide waere der Unterschied sonst nicht mehr zu sehen
|
||
und die Blase verloere ihre Kante. */
|
||
const FL_GRUND = "#F5F4F4", FL_SPALTE = "#F0F0F0", FL_KARTE = "#FFFFFF";
|
||
const CHAT_RAND = "#E5E5E5";
|
||
|
||
/* Eine Farbe fuer alles, was einen Chat kennzeichnet: Projektkanal,
|
||
Gruppe, Direktnachricht. Vorher trug der Projektkanal Lila, die Gruppe
|
||
Grau und jede Person ihre eigene Farbe -- vier Familien in einer Liste,
|
||
und keine davon sagte etwas, das die Zeile nicht schon sagt. Gruen ist
|
||
ohnehin die Farbe der Plakette daneben. */
|
||
const MARKE = { flaeche: "#DCFCE4", rand: "#B7EDC6", zeichen: "#148134" };
|
||
|
||
/* Profilbilder. Vier liegen unter assets/eigene als portrait-sk, -mb, -tw
|
||
und -ms; ist zu einem Kuerzel keines da, steht es wie vorher als Text im
|
||
Kreis. Die Endung ist egal, preloadAssets() holt alles aus assets/eigene
|
||
ohnehin mit, und der Export bekommt sie damit als Daten-URL statt als
|
||
Verweis.
|
||
|
||
KEINE ECHTEN MENSCHEN. Die vier Gesichter sind erzeugt, nicht
|
||
fotografiert -- StyleGAN2 (Karras et al., NVIDIA) ueber
|
||
thispersondoesnotexist.com. Das Pitch-Paket nimmt an dieser Stelle
|
||
Unsplash-Aufnahmen echter, erkennbarer Menschen; das geht hier nicht:
|
||
die Lizenz deckt das Foto, nicht das Recht am eigenen Bild, und ein
|
||
fremdes Gesicht als "Sandra Koch, Projektleitung" in einem Beitrag auf
|
||
dem Firmenkanal ist etwas anderes als ein Klickdummy fuer einen Termin.
|
||
"Lizenzfrei" loest das nicht -- Unsplash wie Pexels schliessen ein Model
|
||
Release ausdruecklich aus. Wo niemand abgebildet ist, stellt sich die
|
||
Frage nicht.
|
||
|
||
Was daran offen bleibt, steht in POST-6.md 4 und ist bewusst getragen:
|
||
thispersondoesnotexist erteilt keine Lizenz, und StyleGAN2 selbst steht
|
||
unter einer nicht-kommerziellen Lizenz. */
|
||
let pfNr = 0;
|
||
const portrait = kuerzel => {
|
||
if (!kuerzel) return null;
|
||
const su = "/eigene/portrait-" + kuerzel.toLowerCase() + ".";
|
||
const t = Object.keys(assets).find(k => k.includes(su));
|
||
return t ? assets[t] : null;
|
||
};
|
||
|
||
/* Ein runder Avatar: Foto, wenn eines da ist, sonst das Kuerzel. Der Rand
|
||
liegt in beiden Faellen darueber, damit die Kante gleich aussieht. */
|
||
function avatarKreis(cx, cy, r, kuerzel) {
|
||
const bild = portrait(kuerzel);
|
||
if (!bild) {
|
||
return '<circle cx="' + n1(cx) + '" cy="' + n1(cy) + '" r="' + r + '" fill="' +
|
||
MARKE.flaeche + '" stroke="' + MARKE.rand + '" stroke-width="1.2"/>' +
|
||
uiText(cx, cy + r * 0.3, kuerzel, r * 0.75, 800, MARKE.zeichen, { anker: "middle" });
|
||
}
|
||
const id = "pfoto" + (++pfNr);
|
||
return '<clipPath id="' + id + '"><circle cx="' + n1(cx) + '" cy="' + n1(cy) +
|
||
'" r="' + r + '"/></clipPath>' +
|
||
'<image href="' + bild + '" x="' + n1(cx - r) + '" y="' + n1(cy - r) + '" width="' +
|
||
n1(2 * r) + '" height="' + n1(2 * r) +
|
||
'" preserveAspectRatio="xMidYMid slice" clip-path="url(#' + id + ')"/>' +
|
||
'<circle cx="' + n1(cx) + '" cy="' + n1(cy) + '" r="' + r +
|
||
'" fill="none" stroke="' + MARKE.rand + '" stroke-width="1.2"/>';
|
||
}
|
||
|
||
/* Die Chatliste links. type steuert das Sinnbild davor: ein Projekt bekommt
|
||
den Aktenkoffer auf farbigem Grund, eine Gruppe die Personen, eine
|
||
Direktnachricht das Kuerzel des Menschen -- so macht es ChatAvatar. */
|
||
const CHATS = [
|
||
{ titel: "# waermepumpe-mueller", vorschau: "Tobias: @Michael Fotos sind im Projektordner.",
|
||
zeit: "14:32", meta: "P-2026-0042", art: "projekt", ungelesen: 3, pin: true, aktiv: true },
|
||
{ titel: "Sandra Koch", vorschau: "Montagetermin ist mit der Familie abgestimmt.",
|
||
zeit: "13:48", meta: "Projektleitung", art: "direkt", ungelesen: 2, pin: true,
|
||
kuerzel: "SK", farbe: "blau" },
|
||
{ titel: "Angebot_v1.pdf · Rückfrage", vorschau: "Maike: Geprüft und freigegeben.",
|
||
zeit: "12:05", meta: "Dokumenten-Thread", art: "projekt", ungelesen: 1 },
|
||
{ titel: "Tobias Wagner", vorschau: "Passt, der Termin steht bei mir.",
|
||
zeit: "11:26", meta: "Montage", art: "direkt", kuerzel: "TW", farbe: "violett" },
|
||
{ titel: "Montage Team Süd", vorschau: "Mehmet: Werkzeugliste ist abgeschickt.",
|
||
zeit: "Gestern", meta: "7 Mitglieder", art: "gruppe" },
|
||
{ titel: "# waermepumpe-abnahme", vorschau: "Lukas: Abnahmeprotokoll hochgeladen.",
|
||
zeit: "Mo", meta: "P-2026-0036", art: "projekt", pin: true },
|
||
{ titel: "Michael Schuster", vorschau: "Ich melde mich nach dem Kundentermin.",
|
||
zeit: "28.07.", meta: "Kundendienst", art: "direkt", kuerzel: "MS", farbe: "gelb" },
|
||
{ titel: "# service-planung", vorschau: "Die Wochenplanung ist abgelegt.",
|
||
zeit: "25.07.", meta: "Service & Wartung", art: "projekt" },
|
||
];
|
||
|
||
/* Gruen statt violett fuer alles Ungelesene: die Plakette und die Uhrzeit
|
||
daneben. Violett ist im Produkt die Farbe der Projekte -- das Symbol des
|
||
Kanals traegt sie, und ein zweites violettes Zeichen in derselben Zeile
|
||
konkurriert damit, statt etwas anderes zu sagen. */
|
||
const UNGELESEN = "#148134";
|
||
|
||
const LISTE_B = 372, ZEILE_H = 96;
|
||
|
||
/* Eine Zeile der Chatliste. Drei Zeilen, wie im Original:
|
||
|
||
Titel Zeit
|
||
Vorschau ●
|
||
P-2026-0042 ⚲
|
||
|
||
Der erste Anlauf hatte nur zwei und schob Nadel und Punkt neben die
|
||
Vorschau -- dort stiessen sie an den Text, und die Projektnummer fehlte
|
||
ganz. Sie ist aber der Punkt: der Kanal haengt an einem Projekt. */
|
||
function chatZeile(x, y, c) {
|
||
let g = "";
|
||
if (c.aktiv) {
|
||
/* Der offene Kanal ist hell hinterlegt, mit Rand und feinem Schatten --
|
||
nicht farbig. Der erste Nachbau hatte ihn violett; im Original steht
|
||
dort bg-surface-neutral-primary mit border und shadow-sm. */
|
||
g += '<g filter="url(#hgklein)"><rect x="' + n1(x) + '" y="' + n1(y) + '" width="' +
|
||
LISTE_B + '" height="' + ZEILE_H + '" rx="10" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>';
|
||
}
|
||
const mx = x + 16, my = y + ZEILE_H / 2;
|
||
if (c.art === "direkt") {
|
||
g += avatarKreis(mx + 20, my, 20, c.kuerzel);
|
||
} else {
|
||
g += '<rect x="' + n1(mx) + '" y="' + n1(my - 20) + '" width="40" height="40" rx="11" ' +
|
||
'fill="' + MARKE.flaeche + '" stroke="' + MARKE.rand + '" stroke-width="1.2"/>';
|
||
g += ikon(c.art === "projekt" ? "briefcase" : "users", mx + 20, my, 21, MARKE.zeichen);
|
||
}
|
||
const tx = mx + 52, rechts = x + LISTE_B - 16, frei = rechts - tx;
|
||
|
||
// Zeile 1: Titel und Uhrzeit.
|
||
const zeitB = messen(c.zeit, 14, 500);
|
||
g += uiText(tx, y + 30, umbrechen(c.titel, frei - zeitB - 12, 17, 700, 1)[0],
|
||
17, 700, CHAT_TEXT);
|
||
g += uiText(rechts, y + 30, c.zeit, 14, 500,
|
||
c.ungelesen ? UNGELESEN : CHAT_TEXT3, { anker: "end" });
|
||
|
||
/* Zeile 2: Vorschau, rechts die Plakette. Sie traegt die Zahl der
|
||
ungelesenen Nachrichten und ist gruen -- ein farbloser Punkt sagt nur
|
||
"da war was", eine Zahl sagt, wie viel. So macht es jeder Messenger,
|
||
den ein Betrieb kennt. */
|
||
const zahl = c.ungelesen ? String(c.ungelesen) : "";
|
||
const zb = zahl ? Math.max(22, messen(zahl, 13, 800) + 14) : 0;
|
||
g += uiText(tx, y + 54, umbrechen(c.vorschau, frei - (zb ? zb + 8 : 0), 15, 500, 1)[0],
|
||
15, 500, c.ungelesen ? CHAT_TEXT : CHAT_TEXT2);
|
||
if (zahl) {
|
||
g += '<rect x="' + n1(rechts - zb) + '" y="' + n1(y + 38) + '" width="' + n1(zb) +
|
||
'" height="22" rx="11" fill="' + UNGELESEN + '"/>';
|
||
g += uiText(rechts - zb / 2, y + 53, zahl, 13, 800, "#FFFFFF", { anker: "middle" });
|
||
}
|
||
|
||
// Zeile 3: die Zuordnung, rechts die Nadel.
|
||
g += uiText(tx, y + 76, c.meta, 13, 500, "#9C9CA3");
|
||
if (c.pin) g += ikon("pin", rechts - 7, y + 71, 13, "#9C9CA3");
|
||
return g;
|
||
}
|
||
|
||
/* Eine Nachricht. Der Aufbau folgt MessageBubble: Kuerzel links, dann eine
|
||
Blase mit Name, Text und -- je nach Art -- einer Anhangkarte darunter,
|
||
zuletzt die Uhrzeit rechtsbuendig. Eigene Nachrichten stehen rechts, ohne
|
||
Kuerzel und ohne Namen, auf gefaerbtem Grund. */
|
||
/* Die Obergrenze fuer eine Blase. Sie haengt an der Spalte, in der die
|
||
Blase steht, nicht an einer festen Zahl: auf der Titelslide ist die
|
||
Unterhaltung schmal, auf der Aufgaben-Slide nimmt sie die ganze Breite.
|
||
Mit einem festen Deckel wurde "Materialliste pruefen · faellig
|
||
31.07.2026" abgeschnitten -- und ein gekuerztes Zitat ist keines mehr. */
|
||
const BLASE_MAX = 470;
|
||
const blaseBreite = spalte => Math.min(BLASE_MAX, spalte - 60);
|
||
|
||
/* Wie breit eine Anhangkarte sein muss, damit ihr Titel ungekuerzt
|
||
hineinpasst. Ohne diese Rechnung war die Blase nur so breit wie ihr
|
||
Fliesstext, und "Materialliste pruefen · faellig 31.07.2026" wurde
|
||
abgeschnitten -- an einer Stelle, an der der Titel die Aussage traegt. */
|
||
const anhangNoetig = a =>
|
||
52 + messen(a[1], 15, 600) + (a[3] ? messen(a[3], 13, 800) + 56 : 0) + 12;
|
||
|
||
/* Wie der Titel einer Anhangkarte in eine gegebene Breite faellt. Hoechstens
|
||
zwei Zeilen -- mehr waere keine Karte mehr, weniger kuerzt das Zitat. */
|
||
const anhangZeilen = (a, b) =>
|
||
umbrechen(a[1], b - 62 - (a[3] ? messen(a[3], 13, 800) + 26 : 0), 15, 600, 2);
|
||
const anhangHoehe = (a, b) => 54 + (anhangZeilen(a, b).length - 1) * 20;
|
||
|
||
function anhangKarte(x, y, b, zeichen, titel, meta, marke) {
|
||
const zeilen = anhangZeilen([zeichen, titel, meta, marke], b);
|
||
const h = 54 + (zeilen.length - 1) * 20;
|
||
let g = '<rect x="' + n1(x) + '" y="' + n1(y) + '" width="' + n1(b) + '" height="' + h +
|
||
'" rx="9" fill="' + FL_KARTE + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += '<rect x="' + n1(x + 10) + '" y="' + n1(y + 11) + '" width="32" height="32" rx="8" ' +
|
||
'fill="' + FL_SPALTE + '" stroke="' + CHAT_RAND + '" stroke-width="1.1"/>';
|
||
g += ikon(zeichen, x + 26, y + 27, 17, CHAT_TEXT2);
|
||
zeilen.forEach((z, i) => {
|
||
g += uiText(x + 52, y + 24 + i * 20, z, 15, 600, CHAT_TEXT);
|
||
});
|
||
g += uiText(x + 52, y + 43 + (zeilen.length - 1) * 20, meta, 13, 500, CHAT_TEXT3);
|
||
if (marke) {
|
||
const mb = Math.round(messen(marke, 13, 800)) + 20;
|
||
g += '<rect x="' + n1(x + b - 10 - mb) + '" y="' + n1(y + 16) + '" width="' + mb +
|
||
'" height="22" rx="11" fill="#FCF6C5" stroke="#faeb8e" stroke-width="1.2"/>' +
|
||
uiText(x + b - 10 - mb / 2, y + 32, marke, 13, 800, "#9B5E0D", { anker: "middle" });
|
||
}
|
||
return g;
|
||
}
|
||
|
||
function blaseMasse(m, max) {
|
||
const innen = (max || BLASE_MAX) - 32;
|
||
const zeilen = m.text ? umbrechen(m.text, innen, 17, 400) : [];
|
||
const innenB = (max || BLASE_MAX) - 32;
|
||
/* Eine Blase kann eine zweite Anhangkarte tragen: die Aufgabe, die aus
|
||
ihr entstanden ist. `a2` ist ihr Anteil zwischen 0 und 1 -- damit
|
||
waechst die Blase mit, waehrend die Karte aufklappt, und der ganze
|
||
Verlauf rutscht von selbst nach. */
|
||
const f2 = m.anhang2 ? (m.a2 == null ? 1 : m.a2) : 0;
|
||
let h = 14 + (m.mein ? 0 : 22) + zeilen.length * 25 +
|
||
(m.anhang ? anhangHoehe(m.anhang, innenB) + 8 : 0) +
|
||
(f2 > 0 ? (anhangHoehe(m.anhang2, innenB) + 8) * f2 : 0) + 24;
|
||
let b = 32 + Math.max(
|
||
...zeilen.map(z => messen(z, 17, 400)),
|
||
m.mein ? 0 : messen(m.autor, 14, 800),
|
||
m.anhang ? anhangNoetig(m.anhang) : 0,
|
||
m.anhang2 ? anhangNoetig(m.anhang2) : 0, 90);
|
||
return { zeilen, hoehe: Math.round(h), breite: Math.round(Math.min(b, max || BLASE_MAX)) };
|
||
}
|
||
|
||
let klappNr = 0;
|
||
|
||
function blase(x, y, b, m) {
|
||
// Die Systemmeldung ist keine Blase, sondern eine Zeile in der Mitte.
|
||
if (m.art === "system") {
|
||
/* Umbrechen statt ueberlaufen: in einer schmalen Spalte stand die Zeile
|
||
sonst ueber den Rand der Flaeche hinaus. */
|
||
const ganz = m.text + " · " + m.zeit;
|
||
const zeilen = umbrechen(ganz, b - 60, 15, 500);
|
||
const mx = x + b / 2;
|
||
let g = "";
|
||
zeilen.forEach((z, i) => {
|
||
const tb = messen(z, 15, 500);
|
||
const versatz = i === 0 ? 12 : 0;
|
||
g += uiText(mx + versatz, y + 18 + i * 20, z, 15, 500, CHAT_TEXT3, { anker: "middle" });
|
||
if (i === 0) g += ikon("square-check", mx - tb / 2 - 4, y + 12, 16, "#148134");
|
||
});
|
||
return g;
|
||
}
|
||
const ma = blaseMasse(m, blaseBreite(b));
|
||
const bx = m.mein ? x + b - ma.breite : x + 52;
|
||
let g = "";
|
||
if (!m.mein) {
|
||
g += avatarKreis(x + 20, y + 22, 20, m.kuerzel);
|
||
}
|
||
const kontur = rundRechteck(bx, y, ma.breite, ma.hoehe, 14);
|
||
g += '<g filter="url(#hgschatten)"><path d="' + kontur + '" fill="' +
|
||
(m.mein ? "#C8FFF7" : FL_KARTE) + '"/></g>';
|
||
if (!m.mein) g += '<path d="' + kontur + '" fill="none" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.2"/>';
|
||
let cy = y + 14;
|
||
if (!m.mein) { g += uiText(bx + 16, cy + 14, m.autor, 14, 800, CHAT_TEXT); cy += 22; }
|
||
ma.zeilen.forEach((z, i) => {
|
||
g += uiText(bx + 16, cy + 18 + i * 25, z, 17, 400, CHAT_TEXT);
|
||
});
|
||
cy += ma.zeilen.length * 25;
|
||
if (m.anhang) {
|
||
const ah = anhangHoehe(m.anhang, ma.breite - 32);
|
||
g += anhangKarte(bx + 16, cy + 4, ma.breite - 32, m.anhang[0], m.anhang[1],
|
||
m.anhang[2], m.anhang[3]);
|
||
cy += ah + 8;
|
||
}
|
||
/* Die zweite Karte klappt auf: sie wird von oben freigegeben, nicht
|
||
gestaucht -- eine gestauchte Karte sieht kaputt aus, eine
|
||
aufgedeckte sieht nach Aufklappen aus. */
|
||
const f2 = m.anhang2 ? (m.a2 == null ? 1 : m.a2) : 0;
|
||
if (f2 > .004) {
|
||
const ah = anhangHoehe(m.anhang2, ma.breite - 32);
|
||
const sicht = ah * f2;
|
||
const id = "anhklapp" + (++klappNr);
|
||
g += '<clipPath id="' + id + '"><rect x="' + n1(bx + 14) + '" y="' + n1(cy + 4) +
|
||
'" width="' + n1(ma.breite - 28) + '" height="' + n1(sicht) + '"/></clipPath>' +
|
||
'<g clip-path="url(#' + id + ')" opacity="' + n1(Math.min(1, f2 * 2.2)) + '">' +
|
||
anhangKarte(bx + 16, cy + 4, ma.breite - 32, m.anhang2[0], m.anhang2[1],
|
||
m.anhang2[2], m.anhang2[3]) + '</g>';
|
||
cy += sicht + 8 * f2;
|
||
}
|
||
g += uiText(bx + ma.breite - 16, cy + 16, m.zeit + (m.mein ? " ✓✓" : ""),
|
||
13, 500, CHAT_TEXT3, { anker: "end" });
|
||
return g;
|
||
}
|
||
|
||
const blaseHoehe = (m, spalte) => m.art === "system"
|
||
? 14 + umbrechen(m.text + " · " + m.zeit, spalte - 60, 15, 500).length * 20
|
||
: blaseMasse(m, blaseBreite(spalte)).hoehe;
|
||
|
||
/* Der Verlauf aus dem Kanal # waermepumpe-mueller, woertlich aus
|
||
INITIAL_MESSAGES. Die Reihenfolge ist die der Quelle. */
|
||
const VERLAUF = [
|
||
{ autor: "Sandra Koch", kuerzel: "SK", farbe: "blau", zeit: "08:14",
|
||
text: "Der Montagetermin mit Familie Müller ist abgestimmt. @Michael bitte prüfe noch einmal die Zufahrt und den Stellplatz." },
|
||
{ autor: "Maike Berger", kuerzel: "MB", farbe: "gruen", zeit: "09:12",
|
||
text: "Das überarbeitete Angebot ist raus und liegt auch hier im Projektchat.",
|
||
anhang: ["file-text", "Angebot_v1.pdf · 418 KB", "PDF · heute"] },
|
||
{ autor: "Tobias Wagner", kuerzel: "TW", farbe: "violett", zeit: "09:24",
|
||
text: "Fotos vom Aufstellort und von der Leitungsführung sind da.",
|
||
anhang: ["image", "2 Baustellenfotos", "Galerie · heute"] },
|
||
{ autor: "Ich", kuerzel: "MS", mein: true, zeit: "09:34",
|
||
text: "Danke. Ich habe dazu direkt eine Aufgabe erstellt.",
|
||
anhang: ["square-check", "Materialliste prüfen · fällig 31.07.2026",
|
||
"Tobias Wagner · offen", "Offen"] },
|
||
{ art: "system", zeit: "09:35", text: "Aufgabe „Materialliste prüfen“ im Projekt erstellt" },
|
||
{ autor: "Sandra Koch", kuerzel: "SK", farbe: "blau", zeit: "10:02",
|
||
text: "Ich habe den Vor-Ort-Termin ergänzt.",
|
||
anhang: ["calendar", "Montage vor Ort · 14.09.2026 · 07:30 Uhr", "Kalendereintrag"] },
|
||
];
|
||
|
||
|
||
/* Der Stapel-Look aus post1.html, in SVG nachgebaut. Dort macht ihn CSS
|
||
ueber zwei Blaetter hinter dem Bild:
|
||
|
||
.sheet-mid rotate(-2.4deg) translate(-2.2%, 3%) scale(.975)
|
||
.sheet-back rotate( 3.2deg) translate( 3%, 5.5%) scale(.95)
|
||
|
||
Dieselben Winkel und Verschiebungen, damit die Reihe zusammenpasst. In
|
||
CSS liegt der Drehpunkt in der Mitte und die Prozente beziehen sich auf
|
||
die eigene Groesse -- in SVG heisst das
|
||
translate(mitte) rotate() translate(prozent*groesse) scale() translate(-mitte).
|
||
|
||
Der Stapel traegt zwei Aufgaben: er gibt Tiefe, die ein Schatten allein
|
||
nicht schafft, und er sagt, dass hinter der einen Ansicht weitere
|
||
liegen. Deshalb sind die hinteren Blaetter leer -- sie sind Andeutung,
|
||
keine Kopie. */
|
||
/* Die beiden hinteren Blaetter je: Winkel, Verschiebung in Anteilen der
|
||
eigenen Groesse, Massstab, Fuellung. Reihenfolge wie in CSS -- erst
|
||
skalieren, dann verschieben, dann drehen. */
|
||
const STAPEL_BLAETTER = [
|
||
{ winkel: 3.2, px: 0.03, py: 0.055, skala: 0.95, fuell: "#F7F7F8" },
|
||
{ winkel: -2.4, px: -0.022, py: 0.03, skala: 0.975, fuell: "#FCFCFD" },
|
||
];
|
||
|
||
/* Wie weit ragen die gedrehten Blaetter ueber das vordere hinaus? Gerechnet
|
||
statt geschaetzt: die vier Ecken durch dieselbe Kette schicken und den
|
||
groessten Ueberstand nehmen. Der erste Anlauf hatte den Ueberstand
|
||
ignoriert -- die Blaetter standen dann bis zu 24 px ausserhalb der
|
||
92-px-Schutzzone. */
|
||
function stapelRand(b, h) {
|
||
let raus = 0;
|
||
for (const bl of STAPEL_BLAETTER) {
|
||
const co = Math.cos(bl.winkel * Math.PI / 180), si = Math.sin(bl.winkel * Math.PI / 180);
|
||
for (const [ex, ey] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
|
||
// Ecke relativ zur Mitte: skalieren, verschieben, drehen.
|
||
const x = ex * b / 2 * bl.skala + bl.px * b;
|
||
const y = ey * h / 2 * bl.skala + bl.py * h;
|
||
raus = Math.max(raus, Math.abs(x * co - y * si) - b / 2,
|
||
Math.abs(x * si + y * co) - h / 2);
|
||
}
|
||
}
|
||
return Math.ceil(raus) + 2;
|
||
}
|
||
|
||
/* Der Stapel-Look aus post1.html, in SVG nachgebaut. Dort macht ihn CSS
|
||
ueber zwei Blaetter hinter dem Bild, mit genau diesen Winkeln und
|
||
Verschiebungen -- damit die Reihe zusammenpasst.
|
||
|
||
Der Stapel traegt zwei Aufgaben: er gibt Tiefe, die ein Schatten allein
|
||
nicht schafft, und er sagt, dass hinter der einen Ansicht weitere
|
||
liegen. Deshalb sind die hinteren Blaetter leer -- sie sind Andeutung,
|
||
keine Kopie.
|
||
|
||
b und h sind die Groesse des vorderen Blattes. Der Kasten drumherum wird
|
||
um stapelRand() groesser; darum kuemmert sich stapelKasten(). */
|
||
function stapel(b, h, radius, inhalt) {
|
||
const m = stapelRand(b, h);
|
||
const blatt = bl => {
|
||
const cx = b / 2, cy = h / 2;
|
||
return '<g transform="translate(' + n1(cx + m) + ',' + n1(cy + m) + ') rotate(' +
|
||
bl.winkel + ') translate(' + n1(bl.px * b) + ',' + n1(bl.py * h) + ') scale(' +
|
||
bl.skala + ') translate(' + n1(-cx) + ',' + n1(-cy) + ')">' +
|
||
'<g filter="url(#hgschatten)"><path d="' + rundRechteck(0, 0, b, h, radius) +
|
||
'" fill="' + bl.fuell + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g></g>';
|
||
};
|
||
return blatt(STAPEL_BLAETTER[0]) + blatt(STAPEL_BLAETTER[1]) +
|
||
'<g transform="translate(' + m + ',' + m + ')">' +
|
||
'<g filter="url(#hgtief)"><path d="' + rundRechteck(0, 0, b, h, radius) +
|
||
'" fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>' + inhalt + '</g>';
|
||
}
|
||
|
||
// Der Kasten, in den ein Stapel dieser Groesse hineinpasst.
|
||
const stapelKasten = (b, h, radius, inhalt) => {
|
||
const m = stapelRand(b, h);
|
||
return wfSvg(b + 2 * m, h + 2 * m, stapel(b, h, radius, inhalt));
|
||
};
|
||
|
||
/* ─── Slide 1 · Der Chat ─────────────────────────────────────────────────
|
||
Kanalliste links, Verlauf rechts. Im Profilraster ist die Slide eine
|
||
Kachel von rund 150 px -- dort ist kein Wort lesbar, und was ankommt,
|
||
sind zwei Spalten mit Blasen. Daran erkennt jemand einen Chat, bevor er
|
||
etwas gelesen hat. Dieselbe Erwaegung wie bei Post 3, 4 und 5. */
|
||
function visChat() {
|
||
/* PAD ist der Abstand zwischen Blattkante und Inhalt. Bei 26 stand das
|
||
Sinnbild in der Kopfzeile knapp 19 px unter dem Rand und sah
|
||
angeschnitten aus. */
|
||
const SP = 26, RECHTS = 460, W = LISTE_B + SP + RECHTS, PAD = 42;
|
||
let g = "";
|
||
|
||
/* Die Filterzeile ueber der Liste. Die vier Woerter sind der Typ
|
||
ChatFilter aus der Quelle, mehr Stufen gibt es dort nicht. */
|
||
let fx = 0;
|
||
["Alle", "Projekte", "Direkt", "Ungelesen"].forEach((f, i) => {
|
||
const b = Math.round(messen(f, 15, 700)) + 26;
|
||
g += '<rect x="' + n1(fx) + '" y="0" width="' + b + '" height="32" rx="8" fill="' +
|
||
(i === 0 ? "#24242B" : FL_KARTE) + '" stroke="' + (i === 0 ? "#24242B" : CHAT_RAND) +
|
||
'" stroke-width="1.2"/>';
|
||
g += uiText(fx + b / 2, 21, f, 15, 700, i === 0 ? "#FFFFFF" : CHAT_TEXT2, { anker: "middle" });
|
||
fx += b + 8;
|
||
});
|
||
|
||
/* Die Unterhaltungsspalte liegt auf eigener Flaeche, eine Stufe dunkler
|
||
als die Blasen darauf. Das ist die Tiefe, die vorher fehlte: ohne sie
|
||
schwebten weisse Blasen auf weissem Grund. */
|
||
const listeO = 50;
|
||
CHATS.forEach((c, i) => { g += chatZeile(0, listeO + i * ZEILE_H, c); });
|
||
const listeH = listeO + CHATS.length * ZEILE_H;
|
||
|
||
const kx = LISTE_B + SP;
|
||
/* Keine Flaeche hinter der Unterhaltung. Ein Zwischenstand hatte hier
|
||
drei graue Stufen liegen, um Tiefe zu erzeugen -- das nimmt den
|
||
Zeichnungen aber ihre Durchsichtigkeit und setzt einen Kasten auf den
|
||
Punktgrund, der da nicht hingehoert. Die Tiefe kommt jetzt allein aus
|
||
den gestaffelten Schatten und daraus, dass die Blasen einen Rand
|
||
tragen und der offene Kanal als Karte liegt.
|
||
|
||
Die duenne Linie zwischen Liste und Unterhaltung bleibt: sie steht so
|
||
im Werkzeug (border-r an der Chatliste) und trennt zwei Spalten, ohne
|
||
eine Flaeche zu behaupten. */
|
||
g += '<path d="M' + n1(kx - 14) + ',-6V' + n1(listeH) + '" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.2"/>';
|
||
|
||
// Die Kopfzeile des offenen Kanals, wie in ChatHeader.
|
||
g += '<rect x="' + n1(kx) + '" y="0" width="40" height="40" rx="11" fill="' +
|
||
MARKE.flaeche + '" stroke="' + MARKE.rand + '" stroke-width="1.2"/>';
|
||
g += ikon("briefcase", kx + 20, 20, 20, MARKE.zeichen);
|
||
g += uiText(kx + 52, 17, CHATS[0].titel, 19, 800, CHAT_TEXT);
|
||
g += uiText(kx + 52, 38, CHATS[0].meta + " · 8 Mitglieder · aktiv", 14, 500, CHAT_TEXT3);
|
||
g += '<path d="M' + n1(kx) + ',58H' + n1(kx + RECHTS) + '" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.2"/>';
|
||
|
||
/* Der Verlauf laeuft bis kurz vor das Eingabefeld. Was nicht mehr
|
||
hineinpasst, faellt weg -- ein Chat ist oben abgeschnitten, nicht
|
||
unten, aber fuer ein Standbild ist die Leserichtung wichtiger. */
|
||
const feldH = 64, tippH = 40, platz = listeH - feldH - tippH - 16;
|
||
|
||
/* Die Datumsmarke steht so ueber dem ersten Beitrag des Tages, als
|
||
Badge mit dem Ton brand-primary-subtle. Sie kostet wenig und sagt,
|
||
dass man einen Verlauf sieht und keine Liste. */
|
||
const dt = "Heute · 30.07.2026";
|
||
const dtB = Math.round(messen(dt, 14, 700)) + 28;
|
||
g += '<rect x="' + n1(kx + RECHTS / 2 - dtB / 2) + '" y="72" width="' + dtB +
|
||
'" height="30" rx="15" fill="#FCF6C5" stroke="#faeb8e" stroke-width="1.2"/>' +
|
||
uiText(kx + RECHTS / 2, 92, dt, 14, 700, "#9B5E0D", { anker: "middle" });
|
||
|
||
let y = 118;
|
||
for (const m of VERLAUF) {
|
||
if (y + blaseHoehe(m, RECHTS) > platz) break;
|
||
g += blase(kx, y, RECHTS, m);
|
||
y += blaseHoehe(m, RECHTS) + 14;
|
||
}
|
||
|
||
/* "Maike Berger schreibt gerade …" steht im Werkzeug als eigene Zeile
|
||
ueber dem Eingabefeld. Sie sagt in vier Woertern, dass da jemand
|
||
mitliest -- das traegt eine Standaufnahme besser als jede Bewegung. */
|
||
const tt = "Maike Berger schreibt gerade …";
|
||
const ttB = Math.round(messen(tt, 14, 500)) + 32;
|
||
const ty = listeH - feldH - tippH + 2;
|
||
g += '<g filter="url(#hgklein)"><rect x="' + n1(kx) + '" y="' + n1(ty) + '" width="' +
|
||
ttB + '" height="32" rx="16" fill="' + FL_KARTE + '" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.1"/></g>' +
|
||
uiText(kx + 16, ty + 21, tt, 14, 500, CHAT_TEXT3);
|
||
|
||
// Das Eingabefeld unten, wie in Composer.
|
||
const fy = listeH - feldH;
|
||
g += '<path d="M' + n1(kx) + ',' + n1(fy) + 'H' + n1(kx + RECHTS) + '" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += ikon("plus", kx + 20, fy + 34, 21, CHAT_TEXT2);
|
||
g += '<g filter="url(#hgklein)"><rect x="' + n1(kx + 42) + '" y="' + n1(fy + 14) +
|
||
'" width="' + (RECHTS - 84) + '" height="40" rx="20" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>';
|
||
g += uiText(kx + 62, fy + 40, "Nachricht schreiben …", 16, 400, "#C9C9CE");
|
||
g += ikon("mic", kx + RECHTS - 20, fy + 34, 21, CHAT_TEXT2);
|
||
/* Erst hier verschieben, nicht mittendrin: ein Zwischenstand hatte diese
|
||
Zeile weiter oben stehen, und alles, was danach gezeichnet wurde --
|
||
Datumsmarke, Blasen, Tippzeile, Eingabefeld -- lag um PAD daneben. Auf
|
||
der ganzen Slide faellt das nicht auf, im Ausschnitt sofort: die
|
||
Kuerzel schnitten die Trennlinie. */
|
||
return stapelKasten(W + 2 * PAD, listeH + 2 * PAD, 22,
|
||
'<g transform="translate(' + PAD + ',' + PAD + ')">' + g + '</g>');
|
||
}
|
||
|
||
/* ─── Slide 2 · Die Kanäle ───────────────────────────────────────────────
|
||
Die Chatliste gross. Sie traegt drei Sorten nebeneinander: Projektkanaele
|
||
mit ihrer Projektnummer, Direktnachrichten an einen Menschen und Gruppen.
|
||
Genau das ist der Unterschied zu dem einen Verlauf, in dem sonst alles
|
||
durcheinandergeht. */
|
||
/* Woraus der eine Verlauf besteht, in dem alles zusammenlaeuft. Das sind
|
||
Redaktionsworte und keine Produktstrings -- sie beschreiben die
|
||
Wirklichkeit im Betrieb, nicht die Oberflaeche. Dieselbe Lage wie bei den
|
||
Plaettchen auf Slide 2 von post2.html ("E-Mails", "Zettel im Auto"), und
|
||
dort ist es aus demselben Grund in Ordnung.
|
||
|
||
Gewaehlt sind Woerter, die im Beitrag ohnehin vorkommen. */
|
||
const DURCHEINANDER = [
|
||
["file-text", "Angebot"],
|
||
["calendar", "Termin"],
|
||
["image", "Baustellenfoto"],
|
||
["message-square", "Rückfrage"],
|
||
["square-check", "Materialliste"],
|
||
];
|
||
|
||
/* Wo die Slide aufhoert. Alles links davon landet auf Slide 1, alles
|
||
rechts davon steht auf Slide 2. Die Reihenfolge oben ist danach
|
||
gewaehlt: "Angebot" und "Termin" sind kurz genug, um ganz auf Slide 1 zu
|
||
passen. Kein Plaettchen darf die Kante schneiden -- ein halbes Wort am
|
||
Rand liest sich als Fehler, nicht als Fortsetzung.
|
||
|
||
Die Zeichnung lief einmal ueber beide Slides: die ersten beiden
|
||
Plaettchen standen auf Slide 1, der Rest hier. Das ist wieder
|
||
zurueckgenommen -- sie steht ganz auf ihrer eigenen Seite. */
|
||
|
||
function visKanaele() {
|
||
/* Zwei Haelften auf einer Slide: links das Durcheinander als Zeichnung,
|
||
rechts die Oberflaeche als Ausschnitt. Die Aussage steckt in der
|
||
Gegenueberstellung und nicht in einem der beiden Teile. */
|
||
const PAD = 24;
|
||
const zeigen = CHATS.slice(0, 5);
|
||
const listeH = zeigen.length * ZEILE_H;
|
||
const sb = LISTE_B + 2 * PAD, sh = listeH + 2 * PAD;
|
||
const m = stapelRand(sb, sh);
|
||
|
||
/* Der Ausschnitt steht groesser da als die Zeichnung daneben. Er traegt
|
||
das Produkt, die Plaettchen tragen nur die Ausgangslage -- gleich gross
|
||
gesetzt lasen sich beide als gleichwertig, und das sind sie nicht.
|
||
Gemessen endet die linke Haelfte samt Leitung bei x = 344, also bleibt
|
||
hier Platz. */
|
||
const LISTE_SKALA = 1.28;
|
||
const skB = sb * LISTE_SKALA, skH = sh * LISTE_SKALA, skM = m * LISTE_SKALA;
|
||
const LX = 388; // wo der Kasten der Liste anfaengt
|
||
/* Der Kasten muss den skalierten Stapel samt Ueberstand fassen. Mit fest
|
||
gesetzten 736 ragte das hinterste, gedrehte Blatt fuenf Einheiten
|
||
darunter heraus -- stapelRand() rechnet den Ueberstand fuer die
|
||
unskalierte Groesse, und die Skalierung vergroessert ihn mit. */
|
||
const W = LX + skB + skM;
|
||
const H = Math.max(736, Math.ceil(skH + 2 * skM + 6));
|
||
let g = "";
|
||
|
||
/* Fest gesetzte Lagen statt gewuerfelter: ein Zufallsgenerator gaebe bei
|
||
jedem Neubau ein anderes Bild, und die Slide soll zweimal gleich
|
||
aussehen. */
|
||
const LAGEN = [
|
||
{ x: 36, y: 40, w: -4.5 },
|
||
{ x: 118, y: 190, w: 5.0 },
|
||
{ x: 10, y: 340, w: 3.2 },
|
||
{ x: 104, y: 488, w: -2.6 },
|
||
{ x: 40, y: 632, w: 4.2 },
|
||
];
|
||
const chips = DURCHEINANDER.map((d, i) => ({
|
||
x: LAGEN[i].x, y: LAGEN[i].y, w: LAGEN[i].w,
|
||
b: Math.round(messen(d[1], 19, 700)) + 74, ikon: d[0], text: d[1],
|
||
}));
|
||
|
||
/* Die Leitung faedelt die Plaettchen der Reihe nach auf und wechselt
|
||
dabei die Seite. Sie faengt am ersten an, nicht davor: ein Anlauf von
|
||
links ausserhalb stammte aus der Fassung, in der die Zeichnung ueber
|
||
zwei Slides lief. Die ist zurueckgenommen, und ein Stueck Leitung, das
|
||
aus dem Nichts kommt, verspricht einen Zusammenhang, den es nicht
|
||
gibt. */
|
||
let pfad = "";
|
||
chips.forEach((c, i) => {
|
||
const n = chips[i + 1];
|
||
if (!n) return;
|
||
const rechtsRum = i % 2 === 0;
|
||
const x1 = rechtsRum ? c.x + c.b : c.x;
|
||
const x2 = rechtsRum ? n.x + n.b : n.x;
|
||
const y1 = c.y + 27, y2 = n.y + 27;
|
||
const aus = rechtsRum ? 128 : -104;
|
||
pfad += 'M' + n1(x1) + ',' + n1(y1) +
|
||
'C' + n1(x1 + aus) + ',' + n1(y1 + 30) + ' ' + n1(x2 + aus) + ',' + n1(y2 - 30) +
|
||
' ' + n1(x2) + ',' + n1(y2);
|
||
});
|
||
g += '<path d="' + pfad + '" fill="none" stroke="#F1B729" stroke-width="3" ' +
|
||
'stroke-dasharray="10 12" stroke-linecap="round" opacity="0.85"/>';
|
||
|
||
chips.forEach(c => {
|
||
g += '<g transform="rotate(' + c.w + ',' + n1(c.x + c.b / 2) + ',' + n1(c.y + 27) + ')">' +
|
||
'<g filter="url(#hgklein)"><rect x="' + n1(c.x) + '" y="' + n1(c.y) +
|
||
'" width="' + c.b + '" height="54" rx="14" fill="#FFFFFF" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.4"/></g>' +
|
||
ikon(c.ikon, c.x + 28, c.y + 27, 20, CHAT_TEXT3) +
|
||
uiText(c.x + 50, c.y + 34, c.text, 19, 700, CHAT_TEXT) + '</g>';
|
||
});
|
||
|
||
let liste = "";
|
||
zeigen.forEach((c, i) => { liste += chatZeile(PAD, PAD + i * ZEILE_H, c); });
|
||
g += '<g transform="translate(' + n1(LX - skM) + ',' + n1((H - skH) / 2 - skM) +
|
||
') scale(' + LISTE_SKALA + ')">' + stapel(sb, sh, 20, liste) + '</g>';
|
||
|
||
/* Die Leitung schwingt zwischen den Plaettchen seitlich aus. Der Kasten
|
||
nimmt das mit auf -- sonst stuende sie ausserhalb, und die Randmessung
|
||
kennte eine Zeichnung, die kleiner ist als das, was dasteht. */
|
||
const AUS = 40;
|
||
return wfSvg(W + AUS, H,
|
||
'<g transform="translate(' + AUS + ',0)">' + g + '</g>');
|
||
}
|
||
|
||
/* ─── Slide 3 · Aus der Nachricht wird die Aufgabe ───────────────────────
|
||
Drei Nachrichten aus dem Verlauf: die Fotos von der Baustelle, die
|
||
Aufgabe, die daraus entsteht, und die Systemzeile, die bestaetigt, dass
|
||
sie im Projekt liegt. Das ist der Satz aus dem Redaktionsplan --
|
||
"Foto rein, Aufgabe raus" -- und er steht so im Werkzeug. */
|
||
function visAufgabe() {
|
||
/* Schmaler gesetzt als die Spalte auf Slide 1: dadurch skaliert die
|
||
Zeichnung groesser und fuellt die Flaeche. */
|
||
const W = 470;
|
||
let inhalt = "", y = 0;
|
||
for (const m of VERLAUF.slice(2, 5)) {
|
||
inhalt += blase(0, y, W, m);
|
||
y += blaseHoehe(m, W) + 18;
|
||
}
|
||
return wfSvg(W, y - 18, inhalt);
|
||
}
|
||
|
||
/* ─── Slide 4 · Was noch in den Chat passt ───────────────────────────────
|
||
Das Menue hinter dem Plus im Eingabefeld. Der Trenner sitzt an derselben
|
||
Stelle wie im Composer: darueber, was man anhaengt, darunter, was man
|
||
anlegt.
|
||
|
||
ZWEI ABWEICHUNGEN VON DER QUELLE, beide der Lesbarkeit wegen.
|
||
|
||
Oben faellt das Verb weg. Im Werkzeug heissen die vier Eintraege
|
||
"Dokument senden", "Fotos und Videos senden", "Audio senden" und
|
||
"Kontakt teilen" -- viermal ein Wort, das der Ort schon sagt: wer das
|
||
Menue ueber das Pluszeichen im Eingabefeld geoeffnet hat, weiss, dass er
|
||
etwas schickt. Hier stehen nur die Dinge. Unten bleibt das Verb, weil es
|
||
dort etwas hinzufuegt: "Termin" allein waere ein Anhang, "Termin
|
||
erstellen" ist eine Handlung.
|
||
|
||
Und "Dokument" steht in der Mehrzahl, weil man mehrere anhaengen kann.
|
||
Audio und Kontakt bleiben einzeln -- man schickt eine Aufnahme und einen
|
||
Kontakt. Aus "Fotos und Videos" wird "Medien": dasselbe in einem Wort,
|
||
und die Karte wird noch einmal schmaler.
|
||
|
||
Das Zeichen bei "Kontakt" ist das Teilen-Zeichen und nicht mehr der
|
||
Telefonhoerer. Der Hoerer sagt "anrufen"; hier wird eine Karte
|
||
weitergegeben. */
|
||
const MENUE = [
|
||
["file-text", "Dokumente"],
|
||
["image", "Medien"],
|
||
["mic", "Audio"],
|
||
["share-network", "Kontakt"],
|
||
null,
|
||
["calendar", "Termin erstellen"],
|
||
["square-check", "Aufgabe erstellen"],
|
||
];
|
||
|
||
function visMenue() {
|
||
const ZH = 52, PAD = 14, RAND = 30, GR = 19;
|
||
const TX = PAD + 50; // wo der Text im Menue anfaengt
|
||
const FELD = 76; // Hoehe der Eingabezeile
|
||
const LUFT = 20; // Abstand zwischen Menue und Feld
|
||
|
||
/* Die Breite kommt aus dem laengsten Eintrag statt aus einer festen Zahl.
|
||
Vorher standen hier 470 -- gemessen fuer Beschriftungen mit "senden",
|
||
und danach lief die Karte quer ueber die Slide, mit einem Drittel
|
||
Leerraum rechts. */
|
||
const breit = Math.max(...MENUE.filter(Boolean).map(e => messen(e[1], GR, 500)));
|
||
const W = Math.round(TX + breit + 44);
|
||
const hoehe = 2 * PAD + MENUE.reduce((a, e) => a + (e ? ZH : 13), 0);
|
||
|
||
/* Das Blatt ist breiter als das Menue: die Eingabezeile laeuft darunter
|
||
ueber die ganze Breite, so wie sie es im Fenster tut. */
|
||
const B = RAND + W + 118, H = RAND + hoehe + LUFT + FELD;
|
||
|
||
/* Das Menue als eigene Karte mit eigenem Schatten -- es liegt ueber dem
|
||
Fenster und nicht darin. */
|
||
let g = '<g filter="url(#hgschatten)"><path d="' +
|
||
rundRechteck(RAND, RAND, W, hoehe, 16) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>';
|
||
|
||
let y = RAND + PAD;
|
||
for (const e of MENUE) {
|
||
if (!e) {
|
||
g += '<path d="M' + n1(RAND + PAD) + ',' + n1(y + 6) + 'H' + n1(RAND + W - PAD) +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
y += 13; continue;
|
||
}
|
||
g += ikon(e[0], RAND + PAD + 26, y + ZH / 2, 20, CHAT_TEXT2);
|
||
g += uiText(RAND + PAD + 50, y + ZH / 2 + 7, e[1], GR, 500, CHAT_TEXT);
|
||
y += ZH;
|
||
}
|
||
|
||
/* ── Die Eingabezeile, aus der das Menue kommt ──────────────────────────
|
||
Sie stand hier einmal und war weggenommen worden: die Slide zeige, was
|
||
man anhaengen kann, und dafuer brauche es das Feld nicht. Das stimmte
|
||
nur so lange, wie man wusste, worauf man schaut. Ohne das Feld ist die
|
||
Karte eine Liste im Nichts -- dass sie aufklappt, wenn man im Chat auf
|
||
das Pluszeichen tippt, stand nirgends.
|
||
|
||
Das Pluszeichen traegt deshalb die gruene Marke wie ein gedruecktes
|
||
Element, und der Zeiger steht darauf. Beides zusammen sagt, was der
|
||
Zusammenhang ist, ohne dass ein Wort danebenstehen muss. Das Menue
|
||
oeffnet nach oben, weil es das im Werkzeug auch tut -- unten waere
|
||
kein Platz. */
|
||
const fy = H - FELD, pcx = RAND + 26, pcy = fy + FELD / 2;
|
||
g += '<path d="M0,' + n1(fy) + 'H' + n1(B) + '" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.2"/>';
|
||
g += '<circle cx="' + n1(pcx) + '" cy="' + n1(pcy) + '" r="21" fill="' +
|
||
MARKE.flaeche + '" stroke="' + MARKE.rand + '" stroke-width="1.2"/>';
|
||
g += ikon("plus", pcx, pcy, 21, MARKE.zeichen);
|
||
g += '<rect x="' + n1(pcx + 34) + '" y="' + n1(pcy - 21) + '" width="' +
|
||
n1(B - pcx - 34 - RAND - 44) + '" height="42" rx="21" fill="' + FL_SPALTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(pcx + 54, pcy + 6, "Nachricht schreiben …", 16, 400, "#C9C9CE");
|
||
g += ikon("mic", B - RAND - 12, pcy, 21, CHAT_TEXT2);
|
||
g += zeiger(pcx + 5, pcy + 3, 1.5);
|
||
|
||
return stapelKasten(B, H, 20, g);
|
||
}
|
||
|
||
|
||
/* Das Absperrband der Website, components/sections/ChatDemoSection.tsx:
|
||
accent-yellow auf dunklen Kanten. Mehr wird von dort nicht gebraucht --
|
||
der Chat auf den Ausschnitten kommt aus dem Pitch-Paket. */
|
||
const WEB = {
|
||
band: "#F1B729", bandDunkel: "#171106",
|
||
/* Das Band knickt an der Plattenkante um und kommt dahinter wieder
|
||
hervor. Der Ton dafuer: die Unterseite, die weniger Licht faengt. */
|
||
bandWeg: "#DFA81E",
|
||
};
|
||
|
||
/* ─── Slide 5 · Der Chat für unterwegs ──────────────────────────────────
|
||
Kein Geraet mehr. Vorbild ist die Machart, die Signal und Slack fuer
|
||
genau solche Slides benutzen: zwei Ausschnitte der Oberflaeche, versetzt
|
||
uebereinander, mit eigenem Schatten -- man sieht zwei Zustaende
|
||
nebeneinander, ohne dass ein Telefonrahmen die halbe Flaeche frisst.
|
||
|
||
Ein Zwischenstand hatte hier ein nachgebautes Handy samt Insel und
|
||
Tasten. Das kostet viel Platz fuer etwas, das nichts ueber das Produkt
|
||
sagt, und der Rahmen war ohnehin frei erfunden.
|
||
|
||
Was bleibt, ist das Absperrband der Website: es sagt, dass die App noch
|
||
nicht da ist. Ohne Unschaerfe diesmal -- das Band sagt es deutlich
|
||
genug, und die Ausschnitte sollen lesbar sein.
|
||
|
||
Gezeigt wird nichts Neues: links die Kanalliste von Slide 2, rechts der
|
||
Verlauf von Slide 1, beide schmal gesetzt. */
|
||
function visMobil() {
|
||
const PB = 404, PH = 620, VX = 236, VY = 104, RAD = 30;
|
||
const W = PB + VX, H = PH + VY;
|
||
const platte = (x, y, inhalt) =>
|
||
'<g filter="url(#hgtief)"><path d="' + rundRechteck(x, y, PB, PH, RAD) +
|
||
'" fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.3"/></g>' + inhalt;
|
||
|
||
/* Hinten die Kanalliste. Sie wird am unteren Rand angeschnitten -- so
|
||
liest sie als Ausschnitt einer laengeren Liste und nicht als
|
||
vollstaendiger Kasten. */
|
||
let hinten = '<clipPath id="hgliste"><path d="' + rundRechteck(0, 0, PB, PH, RAD) +
|
||
'"/></clipPath><g clip-path="url(#hgliste)">';
|
||
hinten += uiText(28, 52, "Team Chat", 24, 800, CHAT_TEXT);
|
||
hinten += '<rect x="28" y="72" width="' + (PB - 56) + '" height="42" rx="21" fill="' +
|
||
FL_SPALTE + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
hinten += ikon("message-square", 50, 93, 16, CHAT_TEXT3);
|
||
hinten += uiText(66, 99, "Chats durchsuchen …", 15, 400, "#C9C9CE");
|
||
CHATS.slice(0, 5).forEach((c, i) => { hinten += chatZeile(16, 128 + i * ZEILE_H, c); });
|
||
hinten += '</g>';
|
||
|
||
/* Vorn der Verlauf, unten das Eingabefeld. */
|
||
let vorn = '<clipPath id="hgverlauf"><path d="' +
|
||
rundRechteck(VX, VY, PB, PH, RAD) + '"/></clipPath><g clip-path="url(#hgverlauf)">';
|
||
const kx = VX + 18, kb = PB - 36;
|
||
vorn += '<rect x="' + n1(VX) + '" y="' + n1(VY) + '" width="' + PB +
|
||
'" height="82" fill="#FFFFFF"/>';
|
||
vorn += '<rect x="' + n1(kx) + '" y="' + n1(VY + 20) + '" width="42" height="42" rx="12" ' +
|
||
'fill="' + MARKE.flaeche + '" stroke="' + MARKE.rand + '" stroke-width="1.2"/>';
|
||
vorn += ikon("briefcase", kx + 21, VY + 41, 21, MARKE.zeichen);
|
||
vorn += uiText(kx + 54, VY + 36, "# waermepumpe-mueller", 19, 800, CHAT_TEXT);
|
||
vorn += uiText(kx + 54, VY + 58, "P-2026-0042 · 8 Mitglieder", 15, 500, CHAT_TEXT3);
|
||
vorn += '<path d="M' + n1(VX) + ',' + n1(VY + 82) + 'H' + n1(VX + PB) + '" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
let vy = VY + 100;
|
||
for (const m of [VERLAUF[2], VERLAUF[3], VERLAUF[4]]) {
|
||
const h = blaseHoehe(m, kb);
|
||
vorn += blase(kx, vy, kb, m);
|
||
vy += h + 14;
|
||
}
|
||
const fy = VY + PH - 74;
|
||
vorn += '<rect x="' + n1(VX) + '" y="' + n1(fy - 14) + '" width="' + PB +
|
||
'" height="88" fill="#FFFFFF"/>';
|
||
vorn += '<path d="M' + n1(VX) + ',' + n1(fy - 14) + 'H' + n1(VX + PB) + '" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
vorn += ikon("plus", VX + 34, fy + 26, 20, CHAT_TEXT2);
|
||
vorn += '<rect x="' + n1(VX + 54) + '" y="' + n1(fy + 6) + '" width="' + (PB - 108) +
|
||
'" height="40" rx="20" fill="' + FL_SPALTE + '" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.2"/>';
|
||
vorn += uiText(VX + 74, fy + 32, "Nachricht schreiben …", 16, 400, "#C9C9CE");
|
||
vorn += '</g>';
|
||
|
||
/* Die beiden Ausschnitte leicht verwischt: erkennbar, aber nicht zu
|
||
lesen. Das Band bleibt scharf und ist damit das Einzige, was klar
|
||
dasteht -- genau das soll es auch sein. */
|
||
let g = '<g filter="url(#hgweich)">' + platte(0, 0, hinten) +
|
||
platte(VX, VY, vorn) + '</g>';
|
||
|
||
/* Das Absperrband der Website: -13°, accent-yellow auf dunklen Kanten,
|
||
Schutzhelm und "BALD VERFUEGBAR". Es liegt ueber beiden Platten und
|
||
laeuft an beiden Enden hinter sie -- es sperrt die ganze Sache ab,
|
||
nicht eine Flaeche darin. Wie das gemacht ist, steht weiter unten bei
|
||
der Maske.
|
||
|
||
Die Form ist nicht mehr das Rechteck der Website, sondern ein
|
||
Spruchband mit Falz an beiden Enden. Das ist eine Abweichung und steht
|
||
als solche in POST-6.md 5a.
|
||
|
||
UEBER ist der Rand des Kastens und zugleich der Ueberstand des Bandes
|
||
darin. Beides an einer Zahl, weil die Bandlaenge genau so gerechnet
|
||
ist, dass der gedrehte Kasten des Bandes exakt die Kastenbreite misst:
|
||
bL*cos + bh*sin = W + 2*UEBER. Damit steht nie Geometrie ueber den
|
||
viewBox hinaus, auch wenn ein Teil davon verdeckt ist -- und die
|
||
Randmessung sieht dasselbe wie das Auge. Genau daran hat es schon
|
||
einmal gehakt: getBBox() kennt keine Beschneidung und meldete beim
|
||
ersten, am Geraet abgeschnittenen Band 146 px Ueberstand, den es nicht
|
||
gab (POST-6.md 6). Wer UEBER anfasst, muss beides mitdenken. */
|
||
const UEBER = 84, bh = 92, ty = H / 2 - bh / 2 + 24;
|
||
const bogen = 13 * Math.PI / 180;
|
||
const bL = (W + 2 * UEBER - bh * Math.sin(bogen)) / Math.cos(bogen);
|
||
const bA = (W - bL) / 2, bE = bA + bL;
|
||
|
||
/* Die Woelbung quer ueber die Hoehe: ein Verlauf von hell nach dunkel,
|
||
wie auf einem Band, das leicht gewoelbt ueber der Flaeche liegt. Sie
|
||
liegt auf der vorderen Flaeche und traegt deren Rundung.
|
||
|
||
Das Abdunkeln an den Enden stand vorher hier, in den Koordinaten des
|
||
Bandes. Es steht jetzt weiter unten in denen der Zeichnung: es gehoert
|
||
an die Stelle, wo das Band hinter eine Platte laeuft, und die liegt
|
||
nicht am Bandende. Seit der Falz die Tiefe traegt, ist es nur noch
|
||
halb so kraeftig -- zusammen mit dem ohnehin dunkleren weglaufenden
|
||
Stueck wurde daraus sonst ein brauner Schatten statt eines Bandes. */
|
||
/* ── Der Umschlag an der Plattenkante ────────────────────────────────
|
||
Das Band liegt flach ueber dem Stapel und knickt genau dort um, wo die
|
||
Platte aufhoert -- nicht davor. Hinter ihr laeuft es weiter und kommt
|
||
an ihrer Kante wieder hervor: tiefer, weil es dabei umgeschlagen ist,
|
||
niedriger, weil es vom Betrachter wegfuehrt, und flacher getont, weil
|
||
man von dort seine Unterseite sieht.
|
||
|
||
Ein Zwischenstand setzte den Knick 70 Einheiten *vor* die Kante. Damit
|
||
lag er mitten auf dem Bildschirm, und das Ganze sah aus wie ein
|
||
Spruchband, das jemand daraufgeklebt und in der Mitte geknickt hat.
|
||
Ein Band knickt nicht um nichts: der Knick gehoert an die Kante.
|
||
|
||
Gerechnet wird die Stelle nicht. Die beiden Stuecke reichen absichtlich
|
||
weit in die Platte hinein, und zwei Masken schneiden sie exakt an deren
|
||
Kante ab -- die vordere Flaeche innerhalb der Silhouette, die Stuecke
|
||
ausserhalb. Der Knick sitzt damit immer auf der Kante, auch wenn sich
|
||
die Platten einmal verschieben, und zwischen beiden bleibt die
|
||
Haarlinie der Platte stehen: sie ist die Falzkante.
|
||
|
||
Vorbild ist das klassische Spruchband, aber ohne dessen zwei
|
||
Festlichkeiten: keine Zacken an den Enden und kein Bogen ueber die
|
||
Laenge. Ein gewoelbtes Band ueber einer flachen Oberflaeche sieht nach
|
||
Urkunde aus, und darum geht es nicht. */
|
||
const oT = ty, uT = ty + bh;
|
||
const uVon = W / 2 - (W / 2) / Math.cos(bogen);
|
||
const uBis = W / 2 + (W / 2) / Math.cos(bogen);
|
||
const HERVOR = 78; // wie weit das Stueck hinter der Platte hervorsteht
|
||
const VERSATZ = 40; // wie tief es dabei haengt
|
||
const SCHMAL = .72; // seine Hoehe: verkuerzt, weil es wegfuehrt
|
||
const NEIG = 10; // zusaetzlicher Fall nach aussen
|
||
const hT = bh * SCHMAL;
|
||
const zO = oT + VERSATZ, zU = zO + hT;
|
||
|
||
/* Ein umgeschlagenes Stueck. uI liegt in der Platte und wird von der
|
||
Maske weggenommen, uA steht draussen. */
|
||
const zipfel = (uI, uA) =>
|
||
"M" + n1(uI) + "," + n1(zO) + "L" + n1(uA) + "," + n1(zO + NEIG) +
|
||
"L" + n1(uA) + "," + n1(zU + NEIG) + "L" + n1(uI) + "," + n1(zU) + "Z";
|
||
|
||
const bandForm = "M" + n1(bA) + "," + n1(oT) + "H" + n1(bE) +
|
||
"V" + n1(uT) + "H" + n1(bA) + "Z";
|
||
|
||
const gid = "bandLicht";
|
||
let band =
|
||
'<defs>' +
|
||
'<linearGradient id="' + gid + '" x1="0" y1="0" x2="0" y2="1">' +
|
||
'<stop offset="0" stop-color="#F8C64A"/>' +
|
||
'<stop offset="0.38" stop-color="' + WEB.band + '"/>' +
|
||
'<stop offset="1" stop-color="#D89F1B"/>' +
|
||
'</linearGradient>' +
|
||
'</defs>' +
|
||
'<rect x="' + n1(bA) + '" y="' + n1(oT) + '" width="' + n1(bL) +
|
||
'" height="' + bh + '" fill="url(#' + gid + ')"/>';
|
||
|
||
/* Die dunklen Laengskanten. Die untere sitzt eine Spur dicker: dort liegt
|
||
die Kante im Schatten der Woelbung. */
|
||
band += '<path d="M' + n1(bA) + ',' + n1(oT) + 'H' + n1(bE) + '" stroke="' +
|
||
WEB.bandDunkel + '" stroke-width="4"/>';
|
||
band += '<path d="M' + n1(bA) + ',' + n1(uT) + 'H' + n1(bE) + '" stroke="' +
|
||
WEB.bandDunkel + '" stroke-width="5"/>';
|
||
// Ein heller Streifen knapp unter der Oberkante: das Glanzlicht.
|
||
band += '<path d="M' + n1(bA) + ',' + n1(oT + 9) + 'H' + n1(bE) +
|
||
'" stroke="#FFFFFF" stroke-width="2.4" opacity=".28"/>';
|
||
|
||
/* Die beiden umgeschlagenen Stuecke. Sie stehen getrennt, weil sie eine
|
||
andere Maske tragen als die vordere Flaeche. Die Aussenkante quer
|
||
darueber schliesst sie ab -- ohne sie endet das Stueck offen. */
|
||
let zipfelHtml = "", zipfelForm = "";
|
||
for (const [uI, uA] of [[uVon + 70, uVon - HERVOR], [uBis - 70, uBis + HERVOR]]) {
|
||
zipfelForm += zipfel(uI, uA);
|
||
zipfelHtml += '<path d="' + zipfel(uI, uA) + '" fill="' + WEB.bandWeg + '"/>';
|
||
zipfelHtml += '<path d="M' + n1(uI) + ',' + n1(zO) + 'L' + n1(uA) + ',' +
|
||
n1(zO + NEIG) + '" stroke="' + WEB.bandDunkel + '" stroke-width="4" fill="none"/>';
|
||
zipfelHtml += '<path d="M' + n1(uI) + ',' + n1(zU) + 'L' + n1(uA) + ',' +
|
||
n1(zU + NEIG) + '" stroke="' + WEB.bandDunkel + '" stroke-width="4" fill="none"/>';
|
||
zipfelHtml += '<path d="M' + n1(uA) + ',' + n1(zO + NEIG) + 'V' + n1(zU + NEIG) +
|
||
'" stroke="' + WEB.bandDunkel + '" stroke-width="4" fill="none"/>';
|
||
}
|
||
|
||
/* Schrift und Helm einmal, mittig auf der geraden Flaeche. Vorher lief
|
||
der Satz wiederholt ueber das ganze Band und begann links am Rand --
|
||
auf einem Band, dessen Enden wegknicken, gehoert er in die Mitte. */
|
||
const wort = "BALD VERFÜGBAR", gr = 34;
|
||
const tw = messen(wort, gr, 800) + gr * 0.16 * wort.length;
|
||
const ganz = 44 + tw;
|
||
const bx = bA + bL / 2 - ganz / 2;
|
||
band += ikon("hard-hat", bx + 18, ty + bh / 2, 36, WEB.bandDunkel);
|
||
band += uiText(bx + 44, ty + bh / 2 + 12, wort, gr, 800, WEB.bandDunkel,
|
||
{ sperr: "0.16em" });
|
||
|
||
/* ── Das Band laeuft um den Stapel herum ──────────────────────────────
|
||
Auf der Website ist es am Rand des Telefons beschnitten: "Clipped to
|
||
the phone's rounded bounds so the ends cut off like real tape"
|
||
(ChatDemoSection.tsx). Hier lief es bisher darueber hinaus, damit es
|
||
die ganze Sache absperrt und nicht eine Flaeche darin. Das war ein
|
||
Gegensatz, den es nicht gibt: beschnitten wird an der Silhouette
|
||
*beider* Ausschnitte, und die ist die ganze Sache.
|
||
|
||
Damit kommt die Tiefe aus dem staerksten Mittel, das es dafuer gibt --
|
||
aus der Verdeckung. Ein Ende, das quer zur Laufrichtung abgeschnitten
|
||
ist, ist das Ende des Bandes; so sah es vorher aus, wie ein Aufkleber,
|
||
der an der Bildkante aufhoert. Ein Ende, das an einer senkrechten
|
||
Plattenkante abbricht, ist keines: dort laeuft das Band hinter die
|
||
Platte, um den Stapel herum, und kommt hinten nicht mehr zum Vorschein.
|
||
|
||
Drei Dinge tragen das, und keines davon verbiegt die Form:
|
||
|
||
die Schnittkante ist die Plattenkante und steht schraeg zum Band
|
||
sie ist genauso weich wie die Platten -- eine unscharfe Kante kann
|
||
nichts scharf verdecken, und die Platten sind unscharf
|
||
die letzten Einheiten davor verlieren Licht, weil die Flaeche sich
|
||
wegdreht
|
||
|
||
Getroffen wird die Silhouette an genau zwei Stellen: links die linke
|
||
Kante der hinteren Platte bei x 0, rechts die rechte der vorderen bei
|
||
x W. Beide sind dort gerade -- die Rundungen liegen weit weg -- und
|
||
beide stehen senkrecht, waehrend das Band mit 13 Grad ankommt. Genau
|
||
diese Schraege ist das Zeichen. */
|
||
const SILH = rundRechteck(0, 0, PB, PH, RAD) + rundRechteck(VX, VY, PB, PH, RAD);
|
||
const dreh = 'rotate(-13,' + n1(W / 2) + ',' + n1(ty + bh / 2) + ')';
|
||
|
||
/* Der Lichtabfall vor der Kante, als Abstand zur Kante und Deckung.
|
||
Nicht linear: eine Falte ist keine Rampe. Die vollen Einheiten stehen
|
||
als Plateau dicht an der Kante, danach laeuft es schnell aus -- so
|
||
liest sich das letzte Stueck als Kante, um die sich das Band legt, und
|
||
nicht als Schatten, der darauf faellt.
|
||
|
||
Zwei Anlaeufe davor: ein linearer Abfall ueber 44 Einheiten sah nach
|
||
Nebel aus, und ein Abfall mit dem Hoehepunkt genau auf der Kante ging
|
||
wieder verloren -- dort blendet die Maske das Band schon aus, das
|
||
Dunkelste lag also im Durchsichtigen. Der Hoehepunkt gehoert ein
|
||
Stueck davor, wo das Band noch voll deckt. */
|
||
const UMSCHLAG = [[0, .42], [6, .30], [16, .14], [38, 0]];
|
||
const halt = (d, o) => '<stop offset="' + (d / W).toFixed(4) +
|
||
'" stop-color="#5A4108" stop-opacity="' + o + '"/>';
|
||
|
||
/* Die Schnittkante ist weicher als eine gezogene Linie und haerter als
|
||
die Platten. Mit deren 2,2 lief das Band ueber vier Einheiten ins
|
||
Weisse aus und sah nach Ausblenden aus statt nach Verdecken; ganz
|
||
scharf wiederum stand eine gestochene Kante an einer unscharfen
|
||
Platte. 1,1 liegt dazwischen: die Kante traegt, ohne zu schneiden.
|
||
|
||
Der schwarze Strich auf derselben Kontur nimmt die halbe Haarlinie der
|
||
Platte zurueck. Ohne ihn endet das Band mitten in der Kante und laesst
|
||
nur deren aeussere Haelfte stehen; die Kante wird dann duenner, genau
|
||
da, wo sie am meisten zu tragen hat. */
|
||
g += '<defs>' +
|
||
'<filter id="bandKante" x="-20%" y="-20%" width="140%" height="140%">' +
|
||
'<feGaussianBlur stdDeviation="1.1"/></filter>' +
|
||
/* Beide Masken kommen aus derselben Kontur, ohne Strich darauf: so
|
||
ergaenzen sich ihre Deckungen an jeder Stelle zu genau eins. Ein
|
||
schwarzer Strich stand hier einmal, um die Haarlinie der Platte
|
||
freizuhalten -- er machte die innere Maske um eine halbe Linie
|
||
kleiner als die aeussere gross war, und in der Luecke dazwischen war
|
||
das Band an beiden Seiten halb durchsichtig. Am gerenderten Blatt
|
||
war das ein blasser Streifen genau im Falz. Die Falzkante kommt
|
||
jetzt als eigener Strich darueber, siehe unten. */
|
||
'<mask id="bandSilhouette" maskUnits="userSpaceOnUse" x="-120" y="-120" ' +
|
||
'width="' + (W + 240) + '" height="' + (H + 240) + '">' +
|
||
'<g filter="url(#bandKante)"><path d="' + SILH + '" fill="#FFFFFF"/></g></mask>' +
|
||
/* Die Gegenmaske fuer die umgeschlagenen Stuecke: alles ausserhalb der
|
||
Silhouette, aus derselben Kontur und mit derselben Weichzeichnung. */
|
||
'<mask id="bandAussen" maskUnits="userSpaceOnUse" x="-120" y="-120" ' +
|
||
'width="' + (W + 240) + '" height="' + (H + 240) + '">' +
|
||
'<rect x="-120" y="-120" width="' + (W + 240) + '" height="' + (H + 240) +
|
||
'" fill="#FFFFFF"/>' +
|
||
'<g filter="url(#bandKante)"><path d="' + SILH + '" fill="#000000"/></g>' +
|
||
'</mask>' +
|
||
/* Der Lichtabfall gilt nur der vorderen Flaeche; die umgeschlagenen
|
||
Stuecke sind ohnehin flach getont. */
|
||
'<clipPath id="bandFlaeche"><path d="' + bandForm +
|
||
'" transform="' + dreh + '"/></clipPath>' +
|
||
/* Band und Stuecke zusammen -- daran wird die Falzkante beschnitten,
|
||
damit sie ueber beide laeuft und an ihrem Rand aufhoert.
|
||
|
||
Zwei getrennte Pfade und nicht einer: als ein Pfad mit mehreren
|
||
Teilstuecken laufen Bandrechteck und Stueck gegenlaeufig, und die
|
||
Fuellregel nonzero loescht ihren Ueberlapp weg. Genau dort, wo die
|
||
Falzkante hin soll, war dadurch ein Loch und die Kante fehlte. Die
|
||
Kinder einer clipPath werden dagegen vereinigt. */
|
||
'<clipPath id="bandGesamt"><path d="' + bandForm + '" transform="' + dreh +
|
||
'"/><path d="' + zipfelForm + '" transform="' + dreh + '"/></clipPath>' +
|
||
/* Und die Stuecke allein: der Lichtabfall gilt auch fuer sie. Er lag
|
||
zuerst nur auf der vorderen Flaeche, und damit war das Stueck an der
|
||
Naht heller als das Band daneben -- verkehrt herum, denn es liegt
|
||
weiter hinten. Da es ganz ausserhalb von 0..W steht, greift der
|
||
Verlauf dort mit seinem vollen Wert. */
|
||
'<clipPath id="bandZipfel"><path d="' + zipfelForm +
|
||
'" transform="' + dreh + '"/></clipPath>' +
|
||
/* Waagerecht in den Koordinaten der Zeichnung, nicht in denen des
|
||
Bandes: der Abfall soll parallel zur senkrechten Plattenkante
|
||
stehen, an der er endet, und nicht schraeg dazu. Der Ton ist ein
|
||
warmes Dunkelbraun und kein Schwarz -- Schwarz macht aus dem Gelb
|
||
ein Grau, und ein Band, das sich wegdreht, wird nicht farblos. */
|
||
'<linearGradient id="bandUmschlag" gradientUnits="userSpaceOnUse" ' +
|
||
'x1="0" y1="0" x2="' + W + '" y2="0">' +
|
||
UMSCHLAG.map(([d, o]) => halt(d, o)).join("") +
|
||
UMSCHLAG.slice().reverse().map(([d, o]) => halt(W - d, o)).join("") +
|
||
'</linearGradient>' +
|
||
'</defs>';
|
||
|
||
/* Um die eigene Mitte drehen, nicht um die der Zeichnung: das Band liegt
|
||
24 Einheiten tiefer, und beim Drehen um einen anderen Punkt wandert
|
||
sein Kasten seitlich aus -- fuenf Einheiten ueber den Rand.
|
||
|
||
Die Maske sitzt auf einer Gruppe darueber und nicht auf dieser hier:
|
||
eine Verdeckung wird in den Koordinaten der Platten gerechnet, und die
|
||
Drehung an derselben Stelle wuerde sie mitdrehen. Ihr Schatten faellt
|
||
damit ebenfalls nur auf die Platten -- neben ihnen ist keine Flaeche,
|
||
die einen aufnehmen koennte. */
|
||
/* Zuerst die umgeschlagenen Stuecke: sie kommen hinter der Platte hervor
|
||
und liegen damit hinter allem. Ihr Schatten faellt auf den Grund neben
|
||
der Platte, nicht auf sie -- dort sind sie ja dahinter. */
|
||
g += '<g mask="url(#bandAussen)">' +
|
||
'<g transform="' + dreh + '" filter="url(#hgtief)">' + zipfelHtml + '</g>' +
|
||
/* Ueber die Zeichnung hinaus: die Stuecke stehen ausserhalb von 0..W,
|
||
ein Rechteck dieser Breite traf sie nie. Der Verlauf haelt draussen
|
||
seinen Randwert, sie bekommen also die volle Abdunklung. */
|
||
'<g clip-path="url(#bandZipfel)"><rect x="-240" y="-240" width="' + (W + 480) +
|
||
'" height="' + (H + 480) + '" fill="url(#bandUmschlag)"/></g>' +
|
||
'</g>';
|
||
g += '<g mask="url(#bandSilhouette)">' +
|
||
'<g transform="' + dreh + '" filter="url(#hgtief)">' + band + '</g>' +
|
||
'<g clip-path="url(#bandFlaeche)"><rect x="0" y="0" width="' + W +
|
||
'" height="' + H + '" fill="url(#bandUmschlag)"/></g>' +
|
||
'</g>';
|
||
/* Die Falzkante. Sie steht senkrecht auf der Plattenkante, waehrend das
|
||
Band mit 13 Grad ankommt -- und sie laeuft ueber die vordere Flaeche
|
||
und das umgeschlagene Stueck hinweg, weil beide an derselben Kante
|
||
zusammenstossen. Ohne sie brechen die dunklen Laengskanten an der Naht
|
||
einfach ab und der Falz sieht zerschnitten aus statt geknickt.
|
||
|
||
Gezeichnet in den Koordinaten der Zeichnung, nicht des Bandes: sie
|
||
gehoert der Platte, nicht dem Band. */
|
||
g += '<g clip-path="url(#bandGesamt)"><path d="M0,0V' + H + 'M' + W + ',0V' + H +
|
||
'" stroke="' + WEB.bandDunkel + '" stroke-width="4.5" fill="none"/></g>';
|
||
return wfSvg(W + 2 * UEBER, H, '<g transform="translate(' + UEBER + ',0)">' + g + '</g>');
|
||
}
|
||
|
||
|
||
/* ─── Das Bewegtbild ────────────────────────────────────────────────────
|
||
Wie aus einer Nachricht eine echte Aufgabe wird -- der Weg, den das
|
||
laufende Werkzeug hat: Nachricht anklicken, Menue oeffnet sich,
|
||
"Aufgabe erstellen".
|
||
|
||
Zwei Anlaeufe lagen davor daneben, beide aus derselben Ursache. Der
|
||
erste liess die Nachricht "Danke. Ich habe dazu direkt eine Aufgabe
|
||
erstellt." tippen und danach eine Aufgabenkarte an ihr wachsen -- sie
|
||
meldete also eine Aufgabe, die es noch nicht gab, und wurde dann selbst
|
||
zu einer. Der zweite nahm das Menue hinter dem Pluszeichen, weil das im
|
||
Pitch-Paket der einzige Weg ist, der dort ausgezeichnet ist.
|
||
|
||
Beides war zu wenig geprueft. Das Pitch-Paket ist ein Stand vom Juli;
|
||
im laufenden Team-Chat haengt an jeder Nachricht ein Menue mit
|
||
Reagieren, Antworten, Im Thread antworten, Anpinnen, Aufgabe erstellen,
|
||
Bearbeiten, Loeschen. Das ist die Funktion, die die Website meint, wenn
|
||
sie schreibt: "Aus 'mach das mal' wird mit einem Klick eine echte
|
||
Aufgabe."
|
||
|
||
Gewaehlt wird Tobias' Nachricht, nicht irgendeine: die Aufgabenkarte aus
|
||
der Quelle traegt "Tobias Wagner · offen" als Zustaendigkeit. Wer sie an
|
||
einer fremden Nachricht entstehen laesst, muss den Namen erfinden.
|
||
|
||
Der Ablauf, in Anteilen der Laufzeit (6 s):
|
||
|
||
0,00 - 0,03 ruhig, der Verlauf steht
|
||
0,03 - 0,19 der Zeiger faehrt auf die Nachricht, der Pfeil erscheint
|
||
0,21 - 0,26 Klick
|
||
0,25 - 0,33 das Menue klappt auf, die Zeilen gestaffelt
|
||
0,40 - 0,54 der Zeiger geht auf "Aufgabe erstellen", die Zeile hebt sich
|
||
0,56 - 0,61 Klick
|
||
0,60 - 0,66 das Menue schliesst, der Zeiger geht
|
||
0,62 - 0,80 die Aufgabenkarte klappt in der Nachricht auf
|
||
0,80 - 0,90 die Bestaetigung im Projekt erscheint
|
||
0,90 - 1,00 Halt auf dem Ergebnis
|
||
|
||
Am Ende wird nichts zurueckgenommen. Ein frueherer Stand blendete die
|
||
Aufgabe wieder aus, damit der Schleifenstoss unsichtbar wird -- das sah
|
||
aus, als loesche sie sich von selbst. Eine Schleife darf springen. */
|
||
|
||
/* Das Menue an der Nachricht, aus dem laufenden Team-Chat. Reihenfolge und
|
||
Beschriftung wie dort; die Symbole sind unsere, weil dort ein Emoji
|
||
steht und wir durchgehend Strichsymbole benutzen. */
|
||
const NACHRICHT_MENUE = [
|
||
["thumbs-up", "Reagieren"],
|
||
["corner-up-left", "Antworten"],
|
||
["message-square", "Im Thread antworten"],
|
||
["pin", "Anpinnen"],
|
||
["square-check", "Aufgabe erstellen"],
|
||
["pencil", "Bearbeiten"],
|
||
["trash-2", "Löschen"],
|
||
];
|
||
const MENUE_ZIEL = 4; // "Aufgabe erstellen"
|
||
|
||
/* Die Aufgabe, die daraus entsteht. Alle vier Felder stehen so in
|
||
GeneralTeamChatPage.tsx an der Nachricht m4. */
|
||
const ANIM_AUFGABE = ["square-check", "Materialliste prüfen · fällig 31.07.2026",
|
||
"Tobias Wagner · offen", "Offen"];
|
||
|
||
const zeiger = (x, y, gr) =>
|
||
'<g transform="translate(' + n1(x) + ',' + n1(y) + ') scale(' + n1(gr) + ')" ' +
|
||
'filter="url(#hgklein)">' +
|
||
'<path d="M0,0 L0,17.5 L4.3,13.4 L7,19.4 L10.1,18 L7.4,12.2 L13,12.2 Z" ' +
|
||
'fill="#FFFFFF" stroke="#24242B" stroke-width="1.5" stroke-linejoin="round"/></g>';
|
||
|
||
/* Der Ring geht auf einer anderen Kurve als er verblasst: der Radius mit
|
||
knall, also fast sofort draussen, die Deckung linear hinterher. Beides auf
|
||
derselben Kurve sah aus wie eine Blase, die sich aufblaest -- ein Klick
|
||
ist ein Stoss und kein Aufpumpen. */
|
||
const klickRing = (x, y, p) => p <= 0 || p >= 1 ? "" :
|
||
'<circle cx="' + n1(x) + '" cy="' + n1(y) + '" r="' + n1(7 + 17 * knall(p)) +
|
||
'" fill="none" stroke="#24242B" stroke-width="' + n1(2.4 * (1 - p)) +
|
||
'" opacity="' + n1(.55 * (1 - p)) + '"/>';
|
||
|
||
/* Es klappt nach oben auf, weil die Nachricht unten im Fenster haengt --
|
||
nach unten waere kein Platz, und ein Menue dreht dort um. */
|
||
function animMenue(rechtsX, untenY, auf, hoverNr) {
|
||
const MW = 262, ZH = 34, PAD = 8;
|
||
const hoehe = 2 * PAD + NACHRICHT_MENUE.length * ZH;
|
||
const x = rechtsX - MW, y = untenY - hoehe;
|
||
|
||
let g = "";
|
||
NACHRICHT_MENUE.forEach((e, nr) => {
|
||
const yy = y + PAD + nr * ZH;
|
||
if (nr === hoverNr)
|
||
g += '<rect x="' + n1(x + PAD - 1) + '" y="' + n1(yy) + '" width="' +
|
||
n1(MW - 2 * PAD + 2) + '" height="' + ZH + '" rx="7" fill="' + FL_SPALTE + '"/>';
|
||
g += ikon(e[0], x + PAD + 18, yy + ZH / 2, 16, CHAT_TEXT2);
|
||
g += uiText(x + PAD + 36, yy + ZH / 2 + 5.5, e[1], 15, 500, CHAT_TEXT);
|
||
});
|
||
|
||
const sk = .94 + .06 * auf;
|
||
return { hoehe,
|
||
zielX: x + PAD + 30,
|
||
zielY: y + PAD + MENUE_ZIEL * ZH + ZH / 2,
|
||
html:
|
||
'<g opacity="' + n1(Math.min(1, auf * 2.5)) + '" transform="translate(' +
|
||
n1(rechtsX) + ',' + n1(untenY) + ') scale(' + n1(sk) + ') translate(' +
|
||
n1(-rechtsX) + ',' + n1(-untenY) + ')">' +
|
||
'<g filter="url(#hgschatten)"><path d="' + rundRechteck(x, y, MW, hoehe, 14) +
|
||
'" fill="#FFFFFF" stroke="' + CHAT_RAND + '" stroke-width="1.2"/></g>' +
|
||
g + '</g>' };
|
||
}
|
||
|
||
function animChat(j, t) {
|
||
const B = 620, H = 740, RAND = 22, KOPF = 74, FELD = 96;
|
||
const innenB = B - 2 * RAND;
|
||
const bahnO = KOPF + 8, bahnU = H - FELD - 8;
|
||
|
||
/* Der Zeiger faehrt bei 0,03 los und nicht mehr bei 0,10. Am Ende steht
|
||
das Bild eine halbe Sekunde still, damit man das Ergebnis liest -- lief
|
||
vorn nochmal eine halbe Sekunde nichts, waren das ueber den
|
||
Schleifenstoss hinweg 1,2 von 6 Sekunden Stillstand am Stueck, und die
|
||
Schleife sah aus, als haenge sie. Der Halt am Ende bleibt, der am
|
||
Anfang ist auf ein Achtel geschrumpft. */
|
||
const zeigerAn = stufe(t, .03, .19);
|
||
const pfeil = daempfen(stufe(t, .13, .21)); // der Pfeil an der Blase
|
||
const klick1 = stufe(t, .21, .26);
|
||
/* rueck statt daempfen, und kuerzer: gedaempft war die Deckung nach
|
||
fuenf Bildern fertig, waehrend die Skalierung noch eine halbe Sekunde
|
||
von 0,96 auf 1,00 weiterkroch. Sichtbar war davon fast nichts, zu
|
||
spueren war es als Haenger. Jetzt schnappt es in einer Drittelsekunde
|
||
auf, schwingt einmal ganz leicht ueber und steht. */
|
||
const auf = rueck(stufe(t, .25, .33));
|
||
const wandern = daempfen(stufe(t, .40, .54));
|
||
const klick2 = stufe(t, .56, .61);
|
||
const zu = daempfen(stufe(t, .60, .66));
|
||
const klappen = daempfen(stufe(t, .62, .80)); // die Aufgabe klappt auf
|
||
/* raus statt daempfen: die Zeile kommt von unten herein und bleibt
|
||
liegen. Eine Kurve, die auch langsam anfaengt, laesst sie zaeh
|
||
aussehen -- sie soll ankommen, nicht anfahren. */
|
||
const system = raus(stufe(t, .80, .90));
|
||
const menueAn = Math.max(0, auf - zu);
|
||
|
||
let g = "";
|
||
|
||
// Kopfzeile des Kanals.
|
||
g += '<rect x="' + RAND + '" y="' + (RAND - 6) + '" width="40" height="40" rx="11" ' +
|
||
'fill="' + MARKE.flaeche + '" stroke="' + MARKE.rand + '" stroke-width="1.2"/>';
|
||
g += ikon("briefcase", RAND + 20, RAND + 14, 20, MARKE.zeichen);
|
||
g += uiText(RAND + 52, RAND + 8, "# waermepumpe-mueller", 20, 800, CHAT_TEXT);
|
||
g += uiText(RAND + 52, RAND + 30, "P-2026-0042 · 8 Mitglieder · aktiv", 15, 500, CHAT_TEXT3);
|
||
g += '<path d="M0,' + KOPF + 'H' + B + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
|
||
/* Der Verlauf haengt unten. Waechst die Blase, schiebt er sich nach oben
|
||
aus dem Fenster -- so entsteht das Scrollen von selbst. */
|
||
const spalte = innenB;
|
||
const m1 = VERLAUF[0], m2 = VERLAUF[1];
|
||
// Tobias' Nachricht bekommt die Aufgabe, die aus ihr entsteht.
|
||
const m3 = Object.assign({}, VERLAUF[2], { anhang2: ANIM_AUFGABE, a2: klappen });
|
||
const sys = VERLAUF[4];
|
||
|
||
const h1 = blaseHoehe(m1, spalte), h2 = blaseHoehe(m2, spalte);
|
||
const h3 = blaseHoehe(m3, spalte);
|
||
const hSys = blaseHoehe(sys, spalte) * system;
|
||
|
||
const LUFT = 16;
|
||
const gesamt = h1 + h2 + h3 + 3 * LUFT + (system > .01 ? hSys + LUFT : 0);
|
||
let y = bahnU - gesamt;
|
||
|
||
let bahn = "";
|
||
bahn += blase(RAND, y, spalte, m1); y += h1 + LUFT;
|
||
bahn += blase(RAND, y, spalte, m2); y += h2 + LUFT;
|
||
|
||
/* Die gewaehlte Nachricht. Ihre Lage wird gemerkt, damit Pfeil, Zeiger
|
||
und Menue daran haengen und nicht an einer zweiten Rechnung. */
|
||
const dritteY = y;
|
||
const ma3 = blaseMasse(m3, blaseBreite(spalte));
|
||
const bx3 = RAND + 52, br3 = ma3.breite;
|
||
bahn += blase(RAND, y, spalte, m3);
|
||
y += h3 + LUFT;
|
||
|
||
if (system > .01) {
|
||
bahn += '<g opacity="' + n1(system) + '" transform="translate(0,' +
|
||
n1((1 - system) * 12) + ')">' + blase(RAND, y, spalte, sys) + '</g>';
|
||
}
|
||
|
||
/* Der Pfeil an der Blase, wie im Werkzeug: er erscheint, sobald die
|
||
Nachricht angefasst wird, und sitzt oben rechts in ihr. */
|
||
if (pfeil > .01 && zu < .5) {
|
||
const px = bx3 + br3 - 18, py = dritteY + 16;
|
||
bahn += '<g opacity="' + n1(pfeil * (1 - stufe(t, .60, .66))) + '">' +
|
||
'<circle cx="' + n1(px) + '" cy="' + n1(py) + '" r="12" fill="' + FL_SPALTE + '"/>' +
|
||
ikon("chevron-down", px, py + 1, 14, CHAT_TEXT2) + '</g>';
|
||
}
|
||
|
||
// Was ueber die Bahn hinausragt, wird oben abgeschnitten -- wie im Fenster.
|
||
g += '<clipPath id="animbahn"><rect x="0" y="' + bahnO + '" width="' + B +
|
||
'" height="' + (bahnU - bahnO + 6) + '"/></clipPath>';
|
||
g += '<g clip-path="url(#animbahn)">' + bahn + '</g>';
|
||
|
||
// Das Eingabefeld bleibt ruhig: hier wird nichts geschrieben.
|
||
const fy = H - FELD;
|
||
g += '<path d="M0,' + n1(fy) + 'H' + B + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += ikon("plus", RAND + 14, fy + 44, 21, CHAT_TEXT2);
|
||
const feldX = RAND + 36, feldB = B - 2 * RAND - 100;
|
||
g += '<g filter="url(#hgklein)"><rect x="' + n1(feldX) + '" y="' + n1(fy + 24) +
|
||
'" width="' + n1(feldB) + '" height="40" rx="20" fill="' + FL_KARTE + '" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/></g>';
|
||
g += uiText(feldX + 18, fy + 50, "Nachricht schreiben …", 16, 400, "#C9C9CE");
|
||
g += ikon("mic", B - RAND - 24, fy + 44, 21, CHAT_TEXT2);
|
||
|
||
/* Das Menue haengt am Pfeil und liegt ueber allem. */
|
||
const chevX = bx3 + br3 - 18, chevY = dritteY + 16;
|
||
const m = animMenue(chevX + 14, chevY - 16, Math.max(auf, .001),
|
||
wandern > .55 && menueAn > .2 ? MENUE_ZIEL : -1);
|
||
/* Unter drei Prozent ist es kein Aufblenden mehr, sondern ein Schemen
|
||
ueber dem Verlauf. Dann lieber gar nichts. */
|
||
if (menueAn > .03) g += m.html;
|
||
|
||
/* Der Zeiger: herein auf den Pfeil, dann auf die Zeile, dann weg. */
|
||
const zWeg = stufe(t, .61, .67);
|
||
const zAn = zeigerAn * (1 - zWeg);
|
||
if (zAn > .01) {
|
||
const vonX = B - 60, vonY = H + 40;
|
||
const zuX = chevX - 2, zuY = chevY - 2;
|
||
/* raus statt daempfen. Gedaempft faehrt der Zeiger langsam an, wird in
|
||
der Mitte schnell und bremst wieder ab -- das ist die Bewegung eines
|
||
Schlittens und nicht die einer Hand. Eine Hand ist sofort schnell und
|
||
wird zum Ziel hin langsam. */
|
||
const p1 = raus(zeigerAn);
|
||
/* Und sie faehrt einen Bogen. Auf der Geraden zwischen Start und Ziel
|
||
liegt kein Zeiger, den je ein Mensch gefuehrt hat; das faellt nicht
|
||
als Fehler auf, aber die Bewegung wirkt gestellt. Der Stuetzpunkt
|
||
sitzt quer zur Strecke, um ein Zehntel ihrer Laenge nach aussen --
|
||
weg vom Verlauf, damit der Zeiger nicht ueber die Blasen streicht.
|
||
Bei p1 = 1 liegt die Bahn exakt auf dem Ziel, das anschliessende
|
||
Wandern zur Menuezeile setzt also sauber daran an. */
|
||
const bx = zuX - vonX, by = zuY - vonY, BOGEN = .13;
|
||
const sx = (vonX + zuX) / 2 - by * BOGEN;
|
||
const sy = (vonY + zuY) / 2 + bx * BOGEN;
|
||
const u = 1 - p1;
|
||
let zx = u * u * vonX + 2 * u * p1 * sx + p1 * p1 * zuX;
|
||
let zy = u * u * vonY + 2 * u * p1 * sy + p1 * p1 * zuY;
|
||
if (wandern > 0) {
|
||
zx += (m.zielX - zx) * wandern;
|
||
zy += (m.zielY - 4 - zy) * wandern;
|
||
}
|
||
g += klickRing(zx + 2, zy + 2, klick1 < 1 ? klick1 : 0);
|
||
g += klickRing(zx + 2, zy + 2, klick2 > 0 && klick2 < 1 ? klick2 : 0);
|
||
g += '<g opacity="' + n1(Math.min(1, zeigerAn * 2) * (1 - zWeg)) + '">' +
|
||
zeiger(zx, zy, 1.5) + '</g>';
|
||
}
|
||
|
||
return stapelKasten(B, H, 22, g);
|
||
}
|
||
|
||
|
||
/* ═══ Post 8 · E-Mail-Marketing ══════════════════════════════════════════
|
||
Quelle ist diesmal nicht das Pitch-Paket, sondern das Produkt selbst:
|
||
hivegrid-cloud/apps/web/app/components/screens/email-marketing/. Das
|
||
Pitch-Paket kennt den Bildschirm nicht -- "Kampagne" kommt dort nur als
|
||
Lead-Quelle im Dashboard vor. Gezeichnet ist also die Oberflaeche, die
|
||
wirklich laeuft.
|
||
|
||
Alle Beschriftungen stehen woertlich in
|
||
apps/web/public/locales/de/translations.json unter emailMarketing:
|
||
die drei Reiter, die Kennzahlen, der Versandverlauf, die sechs Punkte
|
||
der Pre-Send-Pruefung, die Filterfelder, die Ausschlussgruende, der
|
||
Satz auf dem Sendeknopf.
|
||
|
||
WAS ERFUNDEN IST. Der Kampagnenname, die Betreffzeilen und die Zahlen.
|
||
Das ist Demo-Inhalt wie "Waermepumpe Mueller" in Post 5 und 6 -- die
|
||
Quelle liefert keine, weil die Seeds keine Templates anlegen. Die Namen
|
||
der Vorlagen sind dagegen zitiert, aus produkt-daten.ts der Website:
|
||
"Angebotsbestaetigung, Wartungserinnerung, Geburtstagsgruss: alles als
|
||
Vorlage".
|
||
|
||
Die Zahlen gehen auf: 210 Kontakte treffen den Filter, 62 fallen raus
|
||
(31 abgemeldet, 24 ohne Adresse, 7 gesperrt), 148 bleiben -- und 148 ist
|
||
die Zahl auf dem Sendeknopf und bei "Zugestellt". Wer nachrechnet, soll
|
||
nichts finden.
|
||
|
||
Die Farben sind aus ui/theme.css aufgeloest und nicht aus einem Bild
|
||
geschaetzt. Das Muster einer Marke ist immer dasselbe: Flaeche ist der
|
||
100er, Rand der 200er, Schrift der 700er derselben Farbe. */
|
||
const EM_GRUEN = { flaeche: "#DCFCE4", rand: "#BAF8CB", zeichen: "#148134" };
|
||
const EM_GELB = { flaeche: "#FCF6C5", rand: "#FAEB8E", zeichen: "#9B5E0D" };
|
||
const EM_OCEAN = { flaeche: "#E1F4FD", rand: "#BCEAFB", zeichen: "#08739C" };
|
||
const EM_GRAU = { flaeche: "#F0F0F0", rand: "#E5E5E5", zeichen: "#505057" };
|
||
|
||
const EM_B = 720; // Innenbreite der Karte, wie beim Form Builder
|
||
/* Kopfhoehe: Name, Reiter, Trennlinie. Die Linie liegt bei 92, der Inhalt
|
||
beginnt 28 darunter -- derselbe Wert wie unten zum Rand. Dieselbe
|
||
Rechnung wie FB_KOPF in Post 7, wo vier Einheiten zu wenig waren. */
|
||
const EM_KOPF = 120;
|
||
const EM_REITER = ["Kampagnen", "Segmente", "Templates"];
|
||
|
||
/* Eine Marke wie BadgeFeedback. Die Breite haengt am Text, also gibt es sie
|
||
auch einzeln -- der Aufrufer muss oft wissen, wo die Marke endet. */
|
||
const emMarkeB = (t, gr) => messen(t, gr || 14, 700) + (gr || 14) * 1.9;
|
||
function emMarke(x, ym, t, ton, gr) {
|
||
gr = gr || 14;
|
||
const b = emMarkeB(t, gr), h = gr + 13;
|
||
let g = '<rect x="' + n1(x) + '" y="' + n1(ym - h / 2) + '" width="' + n1(b) +
|
||
'" height="' + n1(h) + '" rx="' + n1(h / 2) + '" fill="' + ton.flaeche +
|
||
'" stroke="' + ton.rand + '" stroke-width="1.2"/>';
|
||
g += uiText(x + b / 2, ym + gr * .36, t, gr, 700, ton.zeichen, { anker: "middle" });
|
||
return g;
|
||
}
|
||
|
||
/* Ein Punkt der Pre-Send-Pruefung: Marke mit Haken davor. */
|
||
function emCheck(x, ym, t, ton) {
|
||
const gr = 14, b = messen(t, gr, 700) + 46, h = 30;
|
||
let g = '<rect x="' + n1(x) + '" y="' + n1(ym - h / 2) + '" width="' + n1(b) +
|
||
'" height="' + h + '" rx="' + (h / 2) + '" fill="' + ton.flaeche +
|
||
'" stroke="' + ton.rand + '" stroke-width="1.2"/>';
|
||
g += ikon("check", x + 17, ym, 13, ton.zeichen);
|
||
g += uiText(x + 30, ym + 5, t, gr, 700, ton.zeichen);
|
||
return g;
|
||
}
|
||
|
||
/* Eine Kennzahlkarte, wie StatsCard: Bezeichnung, grosse Zahl, Hinweis. */
|
||
function emKpi(x, y, b, h, label, wert, unten) {
|
||
let g = '<path d="' + rundRechteck(x, y, b, h, 10) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(x + 18, y + 31, label, 14, 700, CHAT_TEXT3);
|
||
g += uiText(x + 18, y + 74, wert, 34, 800, CHAT_TEXT);
|
||
g += uiText(x + 18, y + h - 15, unten, 13, 500, CHAT_TEXT3);
|
||
return g;
|
||
}
|
||
|
||
/* Eine Station des Versandverlaufs. Erreicht: gruene Marke auf gruenem
|
||
Grund. Offen: graue Marke, grauer Grund, und das Datum steht als
|
||
"Noch ausstehend" da -- so steht es in der Quelle. */
|
||
function emStation(x, y, b, h, t, an) {
|
||
const ton = an ? EM_GRUEN : EM_GRAU;
|
||
let g = '<path d="' + rundRechteck(x, y, b, h, 10) + '" fill="' +
|
||
(an ? EM_GRUEN.flaeche : FL_SPALTE) + '" stroke="' +
|
||
(an ? EM_GRUEN.rand : CHAT_RAND) + '" stroke-width="1.2"/>';
|
||
g += emMarke(x + 16, y + 27, an ? "Erreicht" : "Offen", ton, 13);
|
||
g += uiText(x + 16, y + h - 17, t, 15, 700, an ? CHAT_TEXT : CHAT_TEXT3);
|
||
return g;
|
||
}
|
||
|
||
/* Eine Zeile des Filters: Kontaktmerkmal links, Wert rechts, beides als
|
||
Auswahlfeld. Zwei gleich breite Haelften, wie im Baukasten. */
|
||
function emBedingung(x, y, b, feld, wert) {
|
||
const H = 52, luecke = 12, hb = (b - luecke) / 2;
|
||
const kasten = (kx, txt) => '<path d="' + rundRechteck(kx, y, hb, H, 8) + '" fill="' +
|
||
FL_KARTE + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>' +
|
||
uiText(kx + 16, y + H / 2 + 6, txt, 16, 600, CHAT_TEXT) +
|
||
ikon("caret-down", kx + hb - 20, y + H / 2, 13, CHAT_TEXT3);
|
||
return kasten(x, feld) + kasten(x + hb + luecke, wert);
|
||
}
|
||
|
||
/* Eine Zeile der Vorlagenliste: Name, Betreff, Typ. */
|
||
function emVorlage(x, y, b, v, aktiv) {
|
||
const H = 64;
|
||
let g = '<path d="' + rundRechteck(x, y, b, H, 10) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + (aktiv ? EM_OCEAN.zeichen : CHAT_RAND) + '" stroke-width="' +
|
||
(aktiv ? 1.8 : 1.2) + '"/>';
|
||
g += ikon("envelope", x + 30, y + H / 2, 18, CHAT_TEXT2);
|
||
g += uiText(x + 52, y + H / 2 - 4, v.name, 17, 700, CHAT_TEXT);
|
||
g += uiText(x + 52, y + H / 2 + 18, v.betreff, 14, 500, CHAT_TEXT3);
|
||
const mb = emMarkeB(v.typ, 13);
|
||
g += emMarke(x + b - 20 - mb, y + H / 2, v.typ, v.typ === "Standard" ? EM_GRAU : EM_OCEAN, 13);
|
||
return g;
|
||
}
|
||
|
||
/* Der Rahmen, in dem alle vier Zeichnungen sitzen: die Karte mit Kopfzeile,
|
||
Name, Zustandsmarke und den drei Reitern. Welcher Reiter aktiv ist,
|
||
entscheidet die Slide -- die Segmente liegen im Produkt auf einem
|
||
eigenen. */
|
||
function emRahmen(inhaltH, inhalt, o) {
|
||
o = o || {};
|
||
const RAND = 34, KOPF = EM_KOPF, B = EM_B + 2 * RAND;
|
||
const titel = o.titel || "Wartungserinnerung Herbst";
|
||
let g = "";
|
||
g += ikon(o.zeichen || "envelope", RAND + 14, 33, 20, CHAT_TEXT);
|
||
g += uiText(RAND + 32, 40, titel, 20, 800, CHAT_TEXT);
|
||
const nb = messen(titel, 20, 800);
|
||
if (o.marke) g += emMarke(RAND + 46 + nb, 33, o.marke[0], o.marke[1]);
|
||
|
||
let fx = RAND + 4;
|
||
EM_REITER.forEach((r, i) => {
|
||
const aktiv = i === (o.reiter || 0);
|
||
const b = messen(r, 16, aktiv ? 800 : 600) + 34;
|
||
g += uiText(fx + b / 2, 80, r, 16, aktiv ? 800 : 600,
|
||
aktiv ? CHAT_TEXT : CHAT_TEXT3, { anker: "middle" });
|
||
if (aktiv) g += '<path d="M' + n1(fx) + ',92H' + n1(fx + b) + '" stroke="' +
|
||
CHAT_TEXT + '" stroke-width="2.6"/>';
|
||
fx += b + 6;
|
||
});
|
||
g += '<path d="M0,92H' + n1(B) + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += '<g transform="translate(' + RAND + ',' + KOPF + ')">' + inhalt + '</g>';
|
||
const H = o.h !== undefined ? o.h : KOPF + inhaltH + RAND;
|
||
return stapelKasten(B, H, 22, g);
|
||
}
|
||
|
||
/* ─── Slide 2 · Die Zielgruppe ───────────────────────────────────────────
|
||
Der Segment-Baukasten. Die Trefferlogik oben, darunter drei Bedingungen,
|
||
darunter die Vorschau mit der Zahl und den drei Gruenden, aus denen
|
||
Kontakte herausfallen. Die Gruende sind nicht ausgedacht: exclusion.*
|
||
kennt fuenf, drei davon stehen hier. */
|
||
const EM_FILTER = [
|
||
["Kontaktstatus", "Bestandskunde"],
|
||
["Schlagwort", "Heizung"],
|
||
["Primäre E-Mail vorhanden", "Ja"],
|
||
];
|
||
const EM_RAUS = [["Abgemeldet", "31"], ["Keine E-Mail-Adresse", "24"], ["Auf der Sperrliste", "7"]];
|
||
|
||
function visSegment() {
|
||
let g = "", y = 0;
|
||
g += uiText(0, 16, "Trefferlogik", 15, 700, CHAT_TEXT3);
|
||
y = 30;
|
||
g += '<path d="' + rundRechteck(0, y, EM_B, 52, 8) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(16, y + 32, "Alle Bedingungen erfüllen", 16, 600, CHAT_TEXT);
|
||
g += ikon("caret-down", EM_B - 20, y + 26, 13, CHAT_TEXT3);
|
||
y += 52 + 14;
|
||
|
||
EM_FILTER.forEach(([f, w]) => { g += emBedingung(0, y, EM_B, f, w); y += 52 + 10; });
|
||
|
||
g += '<path d="' + rundRechteck(0, y, EM_B, 52, 8) + '" fill="none" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.6" stroke-dasharray="7 6"/>';
|
||
g += ikon("plus", 300, y + 26, 16, CHAT_TEXT3);
|
||
g += uiText(316, y + 32, "Bedingung hinzufügen", 16, 700, CHAT_TEXT3);
|
||
y += 52 + 24;
|
||
|
||
/* Die Vorschau. Die Ueberschrift ist der Satz aus preview.eligible,
|
||
eingesetzt statt umschrieben. */
|
||
const VH = 152;
|
||
g += '<path d="' + rundRechteck(0, y, EM_B, VH, 10) + '" fill="' + EM_OCEAN.flaeche +
|
||
'" stroke="' + EM_OCEAN.rand + '" stroke-width="1.3"/>';
|
||
g += ikon("funnel", 30, y + 34, 18, EM_OCEAN.zeichen);
|
||
g += uiText(52, y + 41, "148 von 210 Kontakten zulässig", 19, 800, CHAT_TEXT);
|
||
const kb = (EM_B - 40 - 2 * 12) / 3;
|
||
EM_RAUS.forEach(([t, n], i) => {
|
||
const kx = 20 + i * (kb + 12), ky = y + 58;
|
||
g += '<path d="' + rundRechteck(kx, ky, kb, 74, 8) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + EM_OCEAN.rand + '" stroke-width="1.2"/>';
|
||
g += uiText(kx + 14, ky + 24, t, 13, 700, CHAT_TEXT3);
|
||
g += uiText(kx + 14, ky + 52, n, 24, 800, CHAT_TEXT);
|
||
g += uiText(kx + 14 + messen(n, 24, 800) + 8, ky + 52, "ausgeschlossen", 12, 500, CHAT_TEXT3);
|
||
});
|
||
y += VH;
|
||
/* Ein Brief in der Kopfzeile waere hier falsch: ein Segment ist keine
|
||
Mail, sondern die Liste derer, die eine bekommen. */
|
||
return emRahmen(y, g, { titel: "Bestandskunden Heizung", reiter: 1,
|
||
zeichen: "funnel", marke: ["Dynamisch", EM_OCEAN] });
|
||
}
|
||
|
||
/* ─── Slide 3 · Die Vorlagen ─────────────────────────────────────────────
|
||
Die Namen stehen in produkt-daten.ts der Website, die Betreffzeilen sind
|
||
geschrieben -- siehe oben. Die vierte Zeile ist "Eigen", damit man sieht,
|
||
dass eigene Vorlagen neben den mitgelieferten stehen; die Marken
|
||
"Standard" und "Eigen" stehen so in der Quelle. */
|
||
const EM_VORLAGEN = [
|
||
{ name: "Wartungserinnerung", betreff: "Ihre Heizung ist bald wieder dran", typ: "Standard" },
|
||
{ name: "Angebotsbestätigung", betreff: "Ihr Angebot von uns", typ: "Standard" },
|
||
{ name: "Geburtstagsgruß", betreff: "Alles Gute von uns", typ: "Standard" },
|
||
{ name: "Neues aus dem Betrieb", betreff: "Was bei uns gerade läuft", typ: "Eigen" },
|
||
];
|
||
|
||
function visVorlagen() {
|
||
let g = "", y = 0;
|
||
g += uiText(0, 16, "Name und Betreff", 15, 700, CHAT_TEXT3);
|
||
g += uiText(EM_B, 16, "Typ", 15, 700, CHAT_TEXT3, { anker: "end" });
|
||
y = 32;
|
||
EM_VORLAGEN.forEach((v, i) => { g += emVorlage(0, y, EM_B, v, i === 0); y += 64 + 10; });
|
||
y += 14;
|
||
|
||
/* Die Vorschau der ersten Vorlage. Sie steht hier nicht als Fueller: die
|
||
Liste allein sagt nur, dass es Vorlagen gibt, und die Slide behauptet,
|
||
dass Betreff und Text schon drinstehen -- das muss man sehen koennen.
|
||
Der Text ist geschrieben, die Klammervariable dagegen echt: der Editor
|
||
weist unter messageHint darauf hin, dass „vorname“ beim Versand
|
||
erhalten bleibt. */
|
||
const VH = 136;
|
||
g += '<path d="' + rundRechteck(0, y, EM_B, VH, 10) + '" fill="' + FL_SPALTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += emMarke(20, y + 28, "Vorschau", EM_GRAU, 13);
|
||
g += uiText(20, y + 68, EM_VORLAGEN[0].betreff, 17, 800, CHAT_TEXT);
|
||
g += uiText(20, y + 95, "Hallo {{vorname}},", 15, 500, CHAT_TEXT2);
|
||
g += uiText(20, y + 118, "die letzte Wartung ist ein Jahr her. Sollen wir einen Termin machen?",
|
||
15, 500, CHAT_TEXT2);
|
||
y += VH + 18;
|
||
|
||
/* Der Knopf aus der Vorlagenliste, woertlich. Er ist der Grund, warum
|
||
die Liste ueberhaupt auf einer Slide steht: eine Vorlage ist kein
|
||
Archiv, sondern der Anfang der naechsten Kampagne. */
|
||
const kt = "Als Ausgangspunkt verwenden";
|
||
const kb = messen(kt, 16, 700) + 60;
|
||
g += '<path d="' + rundRechteck(0, y, kb, 44, 8) + '" fill="#24242B"/>';
|
||
g += ikon("arrow-right", 22, y + 22, 15, "#FFFFFF");
|
||
g += uiText(38, y + 28, kt, 16, 700, "#FFFFFF");
|
||
y += 44;
|
||
return emRahmen(y, g, { titel: "E-Mail-Templates", reiter: 2 });
|
||
}
|
||
|
||
/* ─── Slide 4 · Die Prüfung ──────────────────────────────────────────────
|
||
Die sechs Punkte stehen vollstaendig und in der Reihenfolge der Quelle
|
||
unter presend.check.*. Darunter der Footer, wie ihn der Editor
|
||
voreinstellt: Abmeldelink und Impressum, beides Pflicht. */
|
||
const EM_PRUEFUNG = [
|
||
"Versand-Domain verifiziert", "Abmeldelink vorhanden", "Impressum vorhanden",
|
||
"Betreff gesetzt", "Empfänger vorhanden", "Tageslimit eingehalten",
|
||
];
|
||
|
||
/* Die sechs Angaben des Schritts "Pruefen". Fuenf davon stehen so in der
|
||
Quelle, die sechste ist das Haekchen darunter. Die Werte greifen auf die
|
||
beiden Slides davor zurueck: das Segment heisst hier wie dort, der
|
||
Betreff ist der der gewaehlten Vorlage. Wer die drei Slides
|
||
hintereinander liest, soll dieselbe Kampagne sehen und nicht drei. */
|
||
const EM_UEBERSICHT = [
|
||
["Kampagnenname", "Wartungserinnerung Herbst"],
|
||
["Betreff", "Ihre Heizung ist bald wieder dran"],
|
||
["Absender", "buero@muster-shk.de"],
|
||
["Segment", "Bestandskunden Heizung"],
|
||
["Zeitplanung", "Di, 15.09.2026, 09:00 Uhr"],
|
||
["Öffnungen und Klicks tracken", "An"],
|
||
];
|
||
|
||
function visPruefung() {
|
||
let g = "", y = 0;
|
||
const sb = (EM_B - 12) / 2;
|
||
EM_UEBERSICHT.forEach(([k, v], i) => {
|
||
const bx = (i % 2) * (sb + 12), by = y + Math.floor(i / 2) * 82;
|
||
g += '<path d="' + rundRechteck(bx, by, sb, 72, 8) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(bx + 16, by + 28, k, 13, 700, CHAT_TEXT3);
|
||
g += uiText(bx + 16, by + 54, v, 16, 600, CHAT_TEXT);
|
||
});
|
||
y += 3 * 82 + 12;
|
||
|
||
g += uiText(0, y + 20, "Pre-Send-Prüfung", 19, 800, CHAT_TEXT);
|
||
y += 46;
|
||
/* Zwei Reihen, umgebrochen nach Breite statt nach Anzahl: die Punkte
|
||
sind verschieden lang, drei feste Spalten haetten Luecken gelassen. */
|
||
let zx = 0;
|
||
EM_PRUEFUNG.forEach(t => {
|
||
const b = messen(t, 14, 700) + 46;
|
||
if (zx > 0 && zx + b > EM_B) { zx = 0; y += 42; }
|
||
g += emCheck(zx, y + 15, t, EM_GRUEN);
|
||
zx += b + 12;
|
||
});
|
||
y += 42 + 18;
|
||
|
||
/* Der Footer, wie ihn der Editor voreinstellt. Er stand einmal nicht
|
||
hier, weil "Abmeldelink vorhanden" schon als Punkt in der Pruefung
|
||
steht und zweimal dasselbe wie ein Versehen aussieht. Das war zu
|
||
streng: der Punkt ist die Pruefung, der Kasten ist das Geprueefte --
|
||
und die Ueberschrift der Slide redet vom Abmeldelink, also soll man
|
||
ihn auch sehen. Ohne den Kasten war die Karte ausserdem 150 px
|
||
kuerzer als die beiden Nachbarslides, und die Luft zur Ueberschrift
|
||
entsprechend groesser. Gemessen, nicht geschaetzt. */
|
||
const FH = 108;
|
||
g += '<path d="' + rundRechteck(0, y, EM_B, FH, 10) + '" fill="' + FL_SPALTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(24, y + 32, "Footer mit Abmeldelink und Impressum", 15, 700, CHAT_TEXT3);
|
||
g += '<path d="' + rundRechteck(24, y + 46, EM_B - 48, 44, 8) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(40, y + 74, "Abmelden", 16, 700, EM_OCEAN.zeichen);
|
||
const ab = messen("Abmelden", 16, 700);
|
||
g += uiText(40 + ab + 12, y + 74, "· Impressum: Muster GmbH", 16, 500, CHAT_TEXT2);
|
||
y += FH;
|
||
return emRahmen(y, g, { marke: ["Entwurf", EM_GRAU] });
|
||
}
|
||
|
||
/* ─── Slide 4 · Der Baukasten ────────────────────────────────────────────
|
||
Die Mail, waehrend sie entsteht. Aufbau und Aussehen kommen aus dem
|
||
Klickdummy des Kundentermins, components/subframe-pages/
|
||
S02EinfacherFragenEditor.tsx: Seitengrund F5F4F4, darauf die gestapelten
|
||
Karten, und rechts daneben die schwebende Werkzeugpille. Dort stehen
|
||
Type, Image und Upload schon als Symbole drin -- die Bausteine, die hier
|
||
gebraucht werden, hat das Haus also bereits, nur als Leiste und nicht
|
||
als beschriftete Spalte.
|
||
|
||
ZWEI DINGE, DIE ES SO NOCH NICHT GIBT. Der Bildbaustein und die
|
||
Produktkacheln stehen auf der Website (components/sections/
|
||
EmailBuilder.tsx), aber nicht im Produkt: der Kampagneninhalt ist heute
|
||
ein einziges HTML-Textfeld (EmailMarketingCampaignEditorPage.tsx:199),
|
||
Bilder kennt der Mail-Stack ausser dem Logo keine, und einen
|
||
Artikelstamm gibt es nirgends -- die Migration, die
|
||
"email_marketing_commercial_catalog" heisst, ist HiveGrids eigener
|
||
Tarifkatalog. Die vier Pakete samt Preisen sind also gesetzt wie
|
||
"Waermepumpe Mueller" in Post 5: Beispielinhalt, kein Datenbestand.
|
||
|
||
DER AKZENT IST DIE MARKE. Aufbau und Masse kommen aus S02: ein
|
||
Streifen ueber der Karte (S02:204) und eine Leiste links am
|
||
ausgewaehlten Baustein (S02:299). Dort sind beide in Sky-Blau 0791F0,
|
||
weil die semantische Markenrampe des Produkts auf Grau gemappt ist und
|
||
Gelb dort nur im Logo vorkommt. Hier stehen sie in F1B729, dem Gelb der
|
||
Marke -- eine bewusste Abweichung: auf einem Beitrag soll die Farbe die
|
||
unsere sein und nicht die des Design-Systems.
|
||
|
||
Der Knopf in der Mail ist keine Abweichung. Das Mail-Theme des Produkts,
|
||
packages/mail/src/templates-react/_layout/theme.ts, setzt primary auf
|
||
f1b729 und die Schrift darauf auf 231f20; Button.tsx haengt dort auch
|
||
den Pfeil an. Eine von HiveGrid verschickte Mail hat also wirklich einen
|
||
gelben Knopf mit Pfeil.
|
||
|
||
Alle Farben sind ueber ui/theme.css aufgeloest, nicht aus dem
|
||
Bildschirmfoto gegriffen. */
|
||
const S_MARKE = "#F1B729"; // brand-600, das Gelb aus dem Logo
|
||
const S_MTEXT = "#231F20"; // die Schrift darauf, aus dem Mail-Theme
|
||
const S_STRICH = "#B5B5B7"; // border-strong, jede gestrichelte Flaeche
|
||
const S_GRIFF = "#CECECF"; // text-neutral-subtle, die Ziehgriffe
|
||
|
||
const BK_PALETTE = [
|
||
"text-h-one", "text-t", "image", null, "cursor-click", "squares-four", "minus",
|
||
];
|
||
const BK_PAKETE = [
|
||
["Heizungswartung", "ab 149 €"], ["Wärmepumpe", "ab 3.900 €"],
|
||
["Bad-Sanierung", "ab 6.200 €"], ["Solarthermie", "ab 1.490 €"],
|
||
];
|
||
const BK_TEXT = ["Hallo {{vorname}}, die letzte Wartung Ihrer Heizung ist",
|
||
"ein Jahr her. Sollen wir einen Termin ausmachen?"];
|
||
|
||
/* Eine gestrichelte Flaeche, wie S02:701 und wie das Uploadfeld des
|
||
Produkts (FileUpload.tsx:57): zwei Pixel gestrichelt in border-strong,
|
||
Radius 12, darunter der Seitengrund. */
|
||
function bkGestrichelt(x, y, b, h, zeichen, gr, text, tgr) {
|
||
let g = '<path d="' + rundRechteck(x, y, b, h, 12) + '" fill="' + FL_GRUND +
|
||
'" stroke="' + S_STRICH + '" stroke-width="2" stroke-dasharray="6 6"/>';
|
||
if (text) {
|
||
const tb = messen(text, tgr, 500), ges = gr + 10 + tb;
|
||
g += ikon(zeichen, x + b / 2 - ges / 2 + gr / 2, y + h / 2, gr, CHAT_TEXT3);
|
||
g += uiText(x + b / 2 - ges / 2 + gr + 10, y + h / 2 + tgr * .36, text, tgr, 500, CHAT_TEXT3);
|
||
} else {
|
||
g += ikon(zeichen, x + b / 2, y + h / 2, gr, CHAT_TEXT3);
|
||
}
|
||
return g;
|
||
}
|
||
|
||
function visMail() {
|
||
const PAD = 24, B = EM_B + 2 * 34;
|
||
const PILLE = 50, LUECKE = 20;
|
||
const CX = PAD, CB = B - 2 * PAD - PILLE - LUECKE; // Mailkarte
|
||
const IPAD = 22, IB = CB - 2 * IPAD; // ihr Inneres
|
||
let m = "", y = 8 + IPAD;
|
||
|
||
/* Absenderzeile. Der Kreis ist der Avatar des Produkts: Kreis in
|
||
surface-neutral-tertiary, Kuerzel in 500er Schnitt (Avatar.tsx:37). */
|
||
m += '<circle cx="' + (IPAD + 18) + '" cy="' + (y + 18) + '" r="18" fill="' + FL_SPALTE + '"/>';
|
||
m += uiText(IPAD + 18, y + 24, "MH", 14, 600, CHAT_TEXT2, { anker: "middle" });
|
||
m += uiText(IPAD + 50, y + 15, "Muster Heizung & Sanitär", 15, 700, CHAT_TEXT);
|
||
m += uiText(IPAD + 50, y + 34, "an Anna Berger · a.berger@web.de", 13, 400, CHAT_TEXT3);
|
||
y += 32 + 18;
|
||
m += '<path d="M' + IPAD + ',' + y + 'H' + (CB - IPAD) + '" stroke="' + CHAT_RAND +
|
||
'" stroke-width="1.2"/>';
|
||
y += 22;
|
||
|
||
/* Baustein 1: die Ueberschrift der Mail. */
|
||
m += uiText(IPAD, y + 20, "Ihre Heizung ist bald wieder dran", 20, 700, CHAT_TEXT);
|
||
y += 26 + 16;
|
||
|
||
/* Baustein 2: das Bild -- der ausgewaehlte. Karte mit Rand und der
|
||
sechs Pixel breiten Leiste links, wie die aufgeklappte Frage in
|
||
S02:298. Schatten waeren dort dabei, hier nicht: nichts auf diesen
|
||
Slides wirft einen (rules.md §5). */
|
||
const bh = 118;
|
||
m += '<path d="' + rundRechteck(IPAD - 12, y, IB + 24, bh, 12) + '" fill="#FAFAFA" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
m += '<path d="M' + (IPAD - 12) + ',' + (y + 12) + 'a12,12 0 0 1 6,-11.6V' + (y + bh - .4) +
|
||
'a12,12 0 0 1 -6,-11.6Z" fill="' + S_MARKE + '"/>';
|
||
m += ikon("griff", IPAD + 4, y + bh / 2, 17, S_GRIFF);
|
||
m += bkGestrichelt(IPAD + 20, y + 16, IB - 12, bh - 32, "image", 22, "Bild hierher ziehen", 15);
|
||
m += '<circle cx="' + (CB - IPAD + 4) + '" cy="' + (y + 4) + '" r="15" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
m += ikon("trash-2", CB - IPAD + 4, y + 4, 15, CHAT_TEXT2);
|
||
y += bh + 16;
|
||
|
||
/* Baustein 3: der Text. Die Klammervariable steht so in der Quelle --
|
||
personalization.ts kennt firstName, lastName, email, company_name. */
|
||
BK_TEXT.forEach((z, i) => m += uiText(IPAD, y + 16 + i * 22, z, 15, 400, CHAT_TEXT2));
|
||
y += 40 + 16;
|
||
|
||
/* Baustein 4: der Knopf. Nicht der dunkle Primaerknopf der Oberflaeche,
|
||
sondern der der Mail: _layout/Button.tsx faerbt ihn mit primary f1b729,
|
||
Schrift 231f20 in 700, Radius 10 -- und haengt den Pfeil an. */
|
||
const kt = "Termin anfragen →";
|
||
const kb = messen(kt, 15, 700) + 40;
|
||
m += '<path d="' + rundRechteck(IPAD, y, kb, 40, 10) + '" fill="' + S_MARKE + '"/>';
|
||
m += uiText(IPAD + 20, y + 26, kt, 15, 700, S_MTEXT);
|
||
y += 40 + 18;
|
||
|
||
/* Baustein 5: die Produkte. Die Kachel ist die ProjectCard des Hauses
|
||
(ProjectCard.tsx:200): Radius 12, ein Pixel Rand, FAFAFA -- und der
|
||
Preis gross und fett, dort in 32/600. Im ersten Anlauf stand er hier
|
||
als Kleingedrucktes in 12/400 unter dem Namen und verschwand auf der
|
||
Slide fast. */
|
||
const tb2 = (IB - 12) / 2, th = 94;
|
||
BK_PAKETE.forEach(([name, preis], i) => {
|
||
const tx = IPAD + (i % 2) * (tb2 + 12), ty = y + Math.floor(i / 2) * (th + 12);
|
||
m += '<path d="' + rundRechteck(tx, ty, tb2, th, 12) + '" fill="#FAFAFA" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
m += bkGestrichelt(tx + 10, ty + 10, tb2 - 20, 36, "image", 15, null, 0);
|
||
m += uiText(tx + 12, ty + 64, name, 14, 600, CHAT_TEXT2);
|
||
m += uiText(tx + 12, ty + 85, preis, 18, 700, CHAT_TEXT);
|
||
});
|
||
y += 2 * th + 12 + 18;
|
||
|
||
/* Und die Flaeche, um noch einen zu holen. Wortlaut und Aussehen wie
|
||
"Frage hinzufuegen" in S02:701. */
|
||
m += bkGestrichelt(IPAD, y, IB, 52, "plus", 20, "Baustein hinzufügen", 17);
|
||
y += 52 + IPAD;
|
||
|
||
/* Die Mailkarte: weiss, Radius 12, ein Pixel Rand -- und oben der acht
|
||
Pixel hohe Streifen in Sky, wie ueber der Formularkarte in S02:203. */
|
||
let g = '<path d="' + rundRechteck(0, 0, B, y + 2 * PAD, 22) + '" fill="' + FL_GRUND + '"/>';
|
||
g += '<g transform="translate(' + CX + ',' + PAD + ')">';
|
||
g += '<path d="' + rundRechteck(0, 0, CB, y, 12) + '" fill="' + FL_KARTE + '" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += '<path d="M0,12a12,12 0 0 1 12,-12H' + (CB - 12) + 'a12,12 0 0 1 12,12V8H0Z" fill="' +
|
||
S_MARKE + '"/>';
|
||
g += m + '</g>';
|
||
|
||
/* Die Werkzeugpille rechts. Pillenform, 36er Knoepfe, Symbole in 20 --
|
||
S02:718. Der Trenner in der Mitte trennt die Textbausteine von den
|
||
uebrigen, so wie dort. */
|
||
const px = B - PAD - PILLE;
|
||
let ph = 8, ys = [];
|
||
BK_PALETTE.forEach(z => { ys.push(ph); ph += z ? 36 + 4 : 9 + 4; });
|
||
ph += 4;
|
||
g += '<g transform="translate(' + px + ',' + PAD + ')">';
|
||
g += '<path d="' + rundRechteck(0, 0, PILLE, ph, PILLE / 2) + '" fill="#FAFAFA" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
BK_PALETTE.forEach((z, i) => {
|
||
if (!z) g += '<path d="M' + (PILLE / 2 - 12) + ',' + (ys[i] + 4) + 'h24" stroke="' +
|
||
CHAT_RAND + '" stroke-width="1.2"/>';
|
||
else g += ikon(z, PILLE / 2, ys[i] + 18, 20, CHAT_TEXT2);
|
||
});
|
||
g += '</g>';
|
||
|
||
return stapelKasten(B, y + 2 * PAD, 22, g);
|
||
}
|
||
|
||
/* ─── Das Bewegtbild · die Kampagne geht raus ────────────────────────────
|
||
Der Ablauf, in Anteilen der Laufzeit (6 s):
|
||
|
||
0,00 - 0,05 ruhig, der Entwurf steht, alle Zahlen auf null
|
||
0,05 - 0,20 der Zeiger faehrt auf den Sendeknopf
|
||
0,20 - 0,25 Klick
|
||
0,24 - 0,28 der Knopf blendet weg
|
||
0,24 - 0,32 der Zustand springt auf "Wird gesendet", Station 2 an
|
||
0,32 - 0,36 "Wird gesendet …" blendet an seiner Stelle ein
|
||
0,30 - 0,58 Zugestellt zaehlt auf 148
|
||
0,42 - 0,70 die Oeffnungsrate zaehlt auf 62 %
|
||
0,50 - 0,78 die Klickrate zaehlt auf 18 %
|
||
0,66 - 0,74 "Gesendet", Station 3 an
|
||
0,74 - 0,78 das Ergebnis loest die laufende Meldung ab
|
||
0,58 - 0,70 der Zeiger geht
|
||
0,86 - 1,00 Halt auf dem Ergebnis
|
||
|
||
Warum der Versand und nicht das Bauen der Kampagne: das Bewegtbild ist
|
||
das erste Element im Carousel und traegt den ganzen Beitrag. Was ihn
|
||
traegt, ist nicht der Editor, sondern was hinten herauskommt -- die drei
|
||
Slides danach erklaeren, wie man dahin kommt.
|
||
|
||
Der Versandverlauf hat hier drei Stationen und nicht vier. Die Quelle
|
||
kennt auch "Eingeplant", aber die Kampagne geht hier sofort raus; eine
|
||
Station, die bei diesem Ablauf nie angeht, waere eine Behauptung. */
|
||
const EM_STATIONEN = ["Entwurf erstellt", "Versand gestartet", "Versand abgeschlossen"];
|
||
|
||
/* Drei Empfaenger, damit "aus Kontakten" nicht nur eine Zahl bleibt. Die
|
||
Zustaende sind die der Quelle (recipient.status.*): erst ausstehend, dann
|
||
zugestellt, und bei zweien danach geoeffnet und geklickt. Sie springen
|
||
nacheinander um und nicht gleichzeitig -- drei Zeilen, die im selben Bild
|
||
umschalten, sehen aus wie ein Neuzeichnen und nicht wie drei Menschen,
|
||
die eine Mail bekommen. */
|
||
const EM_EMPFAENGER = [
|
||
{ name: "Anna Berger", mail: "a.berger@web.de", an: .46, spaet: .62, ende: ["Geöffnet", "ocean"] },
|
||
{ name: "Michael Krüger", mail: "m.krueger@gmx.de", an: .52, spaet: .70, ende: ["Geklickt", "gruen"] },
|
||
{ name: "Bauer Haustechnik", mail: "info@bauer-haustechnik.de", an: .58, spaet: null, ende: null },
|
||
];
|
||
|
||
function emEmpfaenger(x, y, b, e, t) {
|
||
const H = 56;
|
||
let g = '<path d="' + rundRechteck(x, y, b, H, 8) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(x + 16, y + H / 2 + 6, e.name, 16, 700, CHAT_TEXT);
|
||
const nb = messen(e.name, 16, 700);
|
||
g += uiText(x + 28 + nb, y + H / 2 + 6, e.mail, 14, 500, CHAT_TEXT3);
|
||
const [txt, ton] = e.spaet !== null && t >= e.spaet ? [e.ende[0], e.ende[1] === "ocean" ? EM_OCEAN : EM_GRUEN]
|
||
: t >= e.an ? ["Zugestellt", EM_GRUEN]
|
||
: ["Ausstehend", EM_GRAU];
|
||
const mb = emMarkeB(txt, 13);
|
||
g += emMarke(x + b - 16 - mb, y + H / 2, txt, ton, 13);
|
||
return g;
|
||
}
|
||
|
||
function animMail(j, t) {
|
||
const zeigerAn = stufe(t, .05, .20);
|
||
const klick = stufe(t, .20, .25);
|
||
const los = stufe(t, .24, .32);
|
||
const fertig = stufe(t, .66, .74);
|
||
/* Der Zeiger geht frueher als in Post 7 (dort .72 bis .86). Er parkt auf
|
||
der Stelle, an der spaeter "148 von 148 zugestellt" steht -- blieb er
|
||
bis .86, stand der Mauspfeil im Haken der Ergebniszeile. Jetzt ist er
|
||
weg, bevor sie ab .74 kommt. */
|
||
const zWeg = stufe(t, .58, .70);
|
||
const zu = daempfen(stufe(t, .30, .58));
|
||
const oe = daempfen(stufe(t, .42, .70));
|
||
const kl = daempfen(stufe(t, .50, .78));
|
||
|
||
const luecke = 12, kb = (EM_B - 2 * luecke) / 3;
|
||
let g = "";
|
||
|
||
/* Die Zeile ganz oben zeigt nacheinander drei Dinge: den Sendeknopf, den
|
||
laufenden Versand, das Ergebnis. Nacheinander heisst hier wirklich
|
||
nacheinander -- das eine ist ganz weg, bevor das naechste anfaengt.
|
||
Ein erster Versuch liess die beiden ineinander ueberblenden, und weil
|
||
alle drei rechtsbuendig an derselben Stelle sitzen, stand zwei Zehntel
|
||
lang "An 148 EmpfWirdggesendet" im Bild. Am fertigen Video gesehen und
|
||
nicht in der Vorschau: dort huscht es vorbei. */
|
||
const oKnopf = 1 - stufe(t, .24, .28);
|
||
const oLaeuft = stufe(t, .32, .36) * (1 - stufe(t, .66, .70));
|
||
const oFertig = stufe(t, .74, .78);
|
||
const ky = 0;
|
||
|
||
const kt = "An 148 Empfänger senden";
|
||
const kbr = messen(kt, 16, 700) + 60, kx = EM_B - kbr;
|
||
if (oKnopf > .01) {
|
||
g += '<g opacity="' + n1(oKnopf) + '"><path d="' + rundRechteck(kx, ky, kbr, 44, 8) +
|
||
'" fill="#24242B"/>';
|
||
g += ikon("paper-plane", kx + 24, ky + 22, 16, "#FFFFFF");
|
||
g += uiText(kx + 42, ky + 28, kt, 16, 700, "#FFFFFF") + '</g>';
|
||
}
|
||
const zustand = (o, tx, zeichen, farbe) => {
|
||
if (o <= .01) return "";
|
||
const tb = messen(tx, 16, 700);
|
||
return '<g opacity="' + n1(o) + '">' +
|
||
uiText(EM_B, ky + 28, tx, 16, 700, farbe, { anker: "end" }) +
|
||
ikon(zeichen, EM_B - tb - 24, ky + 22, 16, farbe) + '</g>';
|
||
};
|
||
g += zustand(oLaeuft, "Wird gesendet …", "paper-plane", CHAT_TEXT3);
|
||
g += zustand(oFertig, "148 von 148 zugestellt", "check-circle", EM_GRUEN.zeichen);
|
||
|
||
let y = 64;
|
||
g += emKpi(0, y, kb, 108, "Zugestellt", String(Math.round(148 * zu)), "Empfänger");
|
||
g += emKpi(kb + luecke, y, kb, 108, "Öffnungsrate",
|
||
Math.round(62 * oe) + " %", "Anteil der Zustellungen");
|
||
g += emKpi(2 * (kb + luecke), y, kb, 108, "Klickrate",
|
||
Math.round(18 * kl) + " %", "Anteil der Zustellungen");
|
||
y += 108 + 26;
|
||
|
||
g += uiText(0, y, "Versandverlauf", 17, 800, CHAT_TEXT);
|
||
y += 18;
|
||
EM_STATIONEN.forEach((s, i) => {
|
||
const an = i === 0 ? 1 : i === 1 ? los : fertig;
|
||
g += emStation(i * (kb + luecke), y, kb, 74, s, an > .5);
|
||
});
|
||
y += 74 + 26;
|
||
|
||
g += uiText(0, y, "Empfänger", 17, 800, CHAT_TEXT);
|
||
y += 18;
|
||
EM_EMPFAENGER.forEach(e => { g += emEmpfaenger(0, y, EM_B, e, t); y += 56 + 8; });
|
||
y -= 8;
|
||
|
||
/* Der Zeiger faehrt von unten rechts auf den Knopf, wie in Post 7. Der
|
||
Bogen ist derselbe: eine gerade Linie sieht aus wie ein Sprung. */
|
||
const zAn = zeigerAn * (1 - zWeg);
|
||
if (zAn > .01) {
|
||
const vonX = EM_B - 40, vonY = y + 60;
|
||
const zuX = kx + 42, zuY = ky + 22;
|
||
const p1 = raus(zeigerAn);
|
||
const bx = zuX - vonX, by = zuY - vonY, BOGEN = .13;
|
||
const sx = (vonX + zuX) / 2 - by * BOGEN, sy = (vonY + zuY) / 2 + bx * BOGEN;
|
||
const u = 1 - p1;
|
||
const zx2 = u * u * vonX + 2 * u * p1 * sx + p1 * p1 * zuX;
|
||
const zy2 = u * u * vonY + 2 * u * p1 * sy + p1 * p1 * zuY;
|
||
g += klickRing(zx2 + 2, zy2 + 2, klick < 1 ? klick : 0);
|
||
g += '<g opacity="' + n1(Math.min(1, zeigerAn * 2) * (1 - zWeg)) + '">' +
|
||
zeiger(zx2, zy2, 1.5) + '</g>';
|
||
}
|
||
|
||
const marke = fertig > .5 ? ["Gesendet", EM_GRUEN]
|
||
: los > .5 ? ["Wird gesendet", EM_GELB]
|
||
: ["Entwurf", EM_GRAU];
|
||
/* Feste Kartenhoehe, ausgerechnet: waechst der Kasten mit dem Inhalt,
|
||
waechst der viewBox, und bei fester Breite auf der Slide schrumpft die
|
||
ganze Zeichnung dabei. Derselbe Fehler wie in Post 7. */
|
||
return emRahmen(0, g, { marke: marke,
|
||
h: EM_KOPF + 64 + 108 + 26 + 18 + 74 + 26 + 18 + 3 * 56 + 2 * 8 + 34 });
|
||
}
|
||
|
||
/* ═══ Post 9 · Webinar ═══════════════════════════════════════════════════
|
||
Was im Bild steht, steht so im Webinar: der Titel, der Untertitel, die
|
||
vier Teile von „Was heute kommt“, die beiden Sprecher.
|
||
|
||
WAS NOCH NICHT FESTSTEHT, steht in eckigen Klammern und nur hier: Datum,
|
||
Wochentag, Uhrzeit, Dauer. Ob die Teilnahme kostenlos ist und ob es eine
|
||
Aufzeichnung gibt, ist ebenfalls offen. Deshalb steht beides nirgends im
|
||
Bild. Steht der Termin, werden hier vier Zeilen geaendert und sonst
|
||
nichts. */
|
||
const WEBINAR = {
|
||
titel: "Automatisierung im Handwerk.",
|
||
untertitel: "Mit dem Branchenprogramm, das du schon hast.",
|
||
datum: "[Datum]",
|
||
wochentag: "[Wochentag]",
|
||
uhrzeit: "[Uhrzeit]",
|
||
dauer: "[Dauer]",
|
||
};
|
||
|
||
/* Die Sprecher. Name und Rolle aus app/ueber-uns/team.ts der Website,
|
||
Noahs Rolle zusammengezogen aus „Handwerksmeister in vierter
|
||
Generation“ und „CEO & Gründer“. Die Fotos sind aus den Teamkarten
|
||
derselben Seite ausgeschnitten (assets/eigene/portrait-ng.jpg und
|
||
-db.jpg). Echte Leute aus dem Team, keine erzeugten Gesichter wie bei
|
||
den Kontakten in Post 6. */
|
||
const WB_SPRECHER = [
|
||
{ kuerzel: "NG", name: "Noah Geerkens", rolle: "Handwerksmeister, Gründer" },
|
||
{ kuerzel: "DB", name: "David Bertram", rolle: "Produkt & Design" },
|
||
];
|
||
|
||
/* Die vier Teile, Wort fuer Wort von „Was heute kommt“. Die Zeichen sind
|
||
die Phosphor-Gegenstuecke zu den Lucide-Zeichen auf derselben Slide:
|
||
Kanban fuer die Pipelines, ein Ablauf fuer die Automation, zwei Blasen
|
||
fuer den Chat, das Geschenk fuer die Ueberraschung. */
|
||
const WB_TEILE = [
|
||
{ rubrik: "Teil 1", zeichen: "kanban", zeilen: ["Von der Anfrage", "zum Auftrag"] },
|
||
{ rubrik: "Teil 2", zeichen: "tree-structure", zeilen: ["Das Herzstück:", "Automatisierung"] },
|
||
{ rubrik: "Teil 3", zeichen: "chats-circle", zeilen: ["Alles drumherum: Chat,", "Personal, Passwörter"] },
|
||
{ rubrik: "Zum Schluss", zeichen: "gift", zeilen: ["Eine Überraschung"] },
|
||
];
|
||
|
||
/* Rot nur fuer die Live-Marke. Aus ui/theme.css von hivegrid-cloud:
|
||
red-100, red-200, red-700 nach dem Muster der Marken in Post 8, der
|
||
Punkt ist red-500. */
|
||
const WB_ROT = { flaeche: "#FEE2E2", rand: "#FDCBCB", zeichen: "#C52020", punkt: "#ED4646" };
|
||
/* Das Sechseck der Webinar-Slides: Gelb #FCE48A, Tinte als Rand. */
|
||
const WB_WABE = "#FCE48A";
|
||
|
||
const WB_B = 720, WB_RAND = 34, WB_KOPF = 92;
|
||
let wbNr = 0;
|
||
|
||
/* Die Live-Marke: Punkt, Wort, und um den Punkt ein Ring, der einmal pro
|
||
Sekunde aufgeht. puls laeuft von 0 bis 1; ohne puls steht der Punkt
|
||
still. */
|
||
function wbLive(x, ym, puls) {
|
||
const gr = 14, h = gr + 13, b = messen("Live", gr, 700) + gr * 1.9 + 14;
|
||
let g = '<rect x="' + n1(x) + '" y="' + n1(ym - h / 2) + '" width="' + n1(b) +
|
||
'" height="' + h + '" rx="' + n1(h / 2) + '" fill="' + WB_ROT.flaeche +
|
||
'" stroke="' + WB_ROT.rand + '" stroke-width="1.2"/>';
|
||
const px = x + 15;
|
||
if (puls !== undefined && puls > 0 && puls < 1) {
|
||
g += '<circle cx="' + n1(px) + '" cy="' + n1(ym) + '" r="' + n1(4 + 6 * raus(puls)) +
|
||
'" fill="' + WB_ROT.punkt + '" opacity="' + n1(.35 * (1 - puls)) + '"/>';
|
||
}
|
||
g += '<circle cx="' + n1(px) + '" cy="' + n1(ym) + '" r="4" fill="' + WB_ROT.punkt + '"/>';
|
||
g += uiText(px + 11, ym + gr * .36, "Live", gr, 700, WB_ROT.zeichen);
|
||
return g;
|
||
}
|
||
|
||
/* Der Rahmen: Zeichen, Titel, Live-Marke, Trennlinie. Wie emRahmen in
|
||
Post 8, nur ohne Reiter. */
|
||
function wbRahmen(h, inhalt, o) {
|
||
const B = WB_B + 2 * WB_RAND;
|
||
let g = ikon(o.zeichen, WB_RAND + 14, 33, 20, CHAT_TEXT);
|
||
g += uiText(WB_RAND + 32, 40, o.titel, 20, 800, CHAT_TEXT);
|
||
g += wbLive(WB_RAND + 46 + messen(o.titel, 20, 800), 33, o.puls);
|
||
/* Rechts der Termin. Ohne ihn sagt das erste Element nur, worum es geht,
|
||
und nicht, wann. */
|
||
if (o.rechts) {
|
||
const rb = messen(o.rechts, 17, 700);
|
||
g += uiText(B - WB_RAND, 40, o.rechts, 17, 700, CHAT_TEXT2, { anker: "end" });
|
||
g += ikon("calendar", B - WB_RAND - rb - 18, 34, 18, CHAT_TEXT2);
|
||
}
|
||
g += '<path d="M0,64H' + n1(B) + '" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += '<g transform="translate(' + WB_RAND + ',' + WB_KOPF + ')">' + inhalt + '</g>';
|
||
return stapelKasten(B, h, 22, g);
|
||
}
|
||
|
||
/* Das Sechseck mit Zeichen, wie auf „Was heute kommt“. */
|
||
function wbWabe(x, y, s, zeichen) {
|
||
const k = s / 56;
|
||
const p = [[3, 28], [15.5, 6.4], [40.5, 6.4], [53, 28], [40.5, 49.6], [15.5, 49.6]]
|
||
.map(q => n1(x + q[0] * k) + "," + n1(y + q[1] * k)).join(" ");
|
||
return '<polygon points="' + p + '" fill="' + WB_WABE + '" stroke="' + CHAT_TEXT +
|
||
'" stroke-width="1.6" stroke-linejoin="round"/>' +
|
||
ikon(zeichen, x + s / 2, y + s / 2, s * .42, CHAT_TEXT);
|
||
}
|
||
|
||
/* Eine Folie auf der Buehne. Sechseck oben rechts, Rubrik oben links,
|
||
der Titel unten links: dieselbe Diagonale wie auf den Kapitel-Slides
|
||
des Webinars. */
|
||
const WB_BUEHNE_H = 430;
|
||
function wbFolie(x0, teil) {
|
||
let g = wbWabe(x0 + WB_B - 44 - 124, 36, 124, teil.zeichen);
|
||
g += uiText(x0 + 48, 78, teil.rubrik.toUpperCase(), 17, 800, CHAT_TEXT3, { sperr: "0.13em" });
|
||
const zh = 56, unten = WB_BUEHNE_H - 94;
|
||
teil.zeilen.forEach((z, i) => {
|
||
g += uiText(x0 + 48, unten - (teil.zeilen.length - 1 - i) * zh, z, 48, 800, CHAT_TEXT);
|
||
});
|
||
return g;
|
||
}
|
||
|
||
/* Eine Kachel fuer einen Sprecher: Foto, Name, Rolle. spricht blendet den
|
||
Rand dunkel und drei Balken rechts ein, wie ein Videoanruf zeigt, wer
|
||
gerade redet. phase treibt die Balken. */
|
||
function wbSprecher(x, y, b, s, spricht, phase) {
|
||
const H = 84;
|
||
let g = '<path d="' + rundRechteck(x, y, b, H, 12) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
if (spricht > .01) {
|
||
g += '<path d="' + rundRechteck(x, y, b, H, 12) + '" fill="none" stroke="' + CHAT_TEXT +
|
||
'" stroke-width="2.2" opacity="' + n1(spricht) + '"/>';
|
||
}
|
||
g += avatarKreis(x + 16 + 26, y + H / 2, 26, s.kuerzel);
|
||
g += uiText(x + 84, y + 38, s.name, 18, 800, CHAT_TEXT);
|
||
g += uiText(x + 84, y + 61, s.rolle, 14, 500, CHAT_TEXT3);
|
||
if (spricht > .01 && phase !== undefined) {
|
||
const bx = x + b - 40, mitte = y + H / 2;
|
||
let balken = "";
|
||
[0, 1, 2].forEach(i => {
|
||
const hoch = 6 + 12 * (.5 + .5 * Math.sin(2 * Math.PI * (phase * (3 + i) + i * .3)));
|
||
balken += '<rect x="' + n1(bx + i * 8) + '" y="' + n1(mitte - hoch / 2) +
|
||
'" width="4" height="' + n1(hoch) + '" rx="2" fill="' + CHAT_TEXT + '"/>';
|
||
});
|
||
g += '<g opacity="' + n1(spricht) + '">' + balken + '</g>';
|
||
}
|
||
return g;
|
||
}
|
||
|
||
/* ─── Das Bewegtbild ──────────────────────────────────────────────────────
|
||
Ein Webinar-Fenster, in dem die vier Teile nacheinander als Folien
|
||
durchlaufen. Jede Folie steht zwei Sekunden, dazwischen schiebt sich die
|
||
naechste von rechts herein, wie im Praesentationsmodus. Unten fuellt
|
||
sich je Teil ein Abschnitt der Leiste.
|
||
|
||
Unter der Buehne die beiden Sprecher. Wer redet, hat den dunklen Rand.
|
||
Die Reihenfolge ist die aus dem Skript: Jedes Kapitel eroeffnet Noah,
|
||
dann uebernimmt David.
|
||
|
||
Alles, was sich wiederholt, laeuft in ganzen Perioden ueber die acht
|
||
Sekunden: der Puls der Live-Marke achtmal, die Balken drei-, vier- und
|
||
fuenfmal je Teil. Ein Video, das sich auf Instagram
|
||
wiederholt, springt dann nur bei der Folie und nicht beim Puls. */
|
||
function animWebinar(j, t) {
|
||
const n = WB_TEILE.length, abschnitt = 1 / n, schub = .045;
|
||
/* Welche Folie steht, und wie weit der Schub zur naechsten ist. */
|
||
let g = "";
|
||
const id = "wbbuehne" + (++wbNr);
|
||
g += '<clipPath id="' + id + '"><path d="' + rundRechteck(0, 0, WB_B, WB_BUEHNE_H, 14) +
|
||
'"/></clipPath>';
|
||
g += '<path d="' + rundRechteck(0, 0, WB_B, WB_BUEHNE_H, 14) + '" fill="' + FL_GRUND +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
|
||
let folien = "";
|
||
for (let i = 0; i < n; i++) {
|
||
/* Folie i kommt bei i*abschnitt herein und geht bei (i+1)*abschnitt.
|
||
Die erste steht von Anfang an, die letzte bleibt bis zum Ende. */
|
||
const rein = i === 0 ? 1 : daempfen(stufe(t, i * abschnitt - schub, i * abschnitt + schub));
|
||
const raus_ = i === n - 1 ? 0 : daempfen(stufe(t, (i + 1) * abschnitt - schub, (i + 1) * abschnitt + schub));
|
||
const x0 = (1 - rein) * WB_B - raus_ * WB_B;
|
||
if (x0 >= WB_B || x0 <= -WB_B) continue;
|
||
folien += wbFolie(x0, WB_TEILE[i]);
|
||
}
|
||
g += '<g clip-path="url(#' + id + ')">' + folien + '</g>';
|
||
|
||
/* Die Leiste: vier Abschnitte, jeder fuellt sich, solange sein Teil
|
||
steht. */
|
||
const LX = 48, LB = WB_B - 96, LL = 8, sb = (LB - (n - 1) * LL) / n, ly = WB_BUEHNE_H - 40;
|
||
for (let i = 0; i < n; i++) {
|
||
const sx = LX + i * (sb + LL);
|
||
g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(sb) +
|
||
'" height="6" rx="3" fill="#DADADC"/>';
|
||
const p = stufe(t, i * abschnitt, (i + 1) * abschnitt);
|
||
if (p > 0) g += '<rect x="' + n1(sx) + '" y="' + ly + '" width="' + n1(Math.max(6, sb * p)) +
|
||
'" height="6" rx="3" fill="' + CHAT_TEXT + '"/>';
|
||
}
|
||
|
||
/* Die Sprecher. Im Abschnitt eines Teils redet erst Noah, ab etwas
|
||
ueber der Haelfte David. Der Wechsel blendet ueber, damit nicht beide
|
||
Raender gleichzeitig hart umspringen. */
|
||
const imTeil = (t % abschnitt) / abschnitt;
|
||
const david = stufe(imTeil, .52, .62);
|
||
const noah = 1 - david;
|
||
const sy = WB_BUEHNE_H + 20, kb = (WB_B - 16) / 2;
|
||
const phase = t * n;
|
||
g += wbSprecher(0, sy, kb, WB_SPRECHER[0], noah, phase);
|
||
g += wbSprecher(kb + 16, sy, kb, WB_SPRECHER[1], david, phase);
|
||
|
||
const h = WB_KOPF + WB_BUEHNE_H + 20 + 84 + WB_RAND;
|
||
return wbRahmen(h, g, { zeichen: "video-camera", titel: "Live-Webinar", puls: (t * 8) % 1,
|
||
rechts: WEBINAR.datum + ", " + WEBINAR.uhrzeit + " Uhr" });
|
||
}
|
||
|
||
/* ─── Das Endbild ─────────────────────────────────────────────────────────
|
||
Die Anmeldung als Karte: Titel und Untertitel des Webinars, darunter
|
||
Datum, Uhrzeit und Ort als drei Kennzahlkarten wie in Post 8, dann die
|
||
Sprecher, dann der Knopf. Unter dem Knopf, wo man sich anmeldet. Auf
|
||
Instagram ist das der Link in der Bio. Laeuft das Bild als Anzeige, traegt
|
||
die Anzeige ihren eigenen Knopf, und die Zeile muss raus. */
|
||
/* Eine Kennzahlkarte wie emKpi in Post 8, eine Stufe groesser: Das
|
||
Endbild ist das, was in der Anzeige stehen bleibt, und Datum und Uhrzeit
|
||
muessen dort auf dem Handy lesbar sein. */
|
||
function wbKpi(x, y, b, h, label, wert, unten) {
|
||
let g = '<path d="' + rundRechteck(x, y, b, h, 12) + '" fill="' + FL_KARTE +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
g += uiText(x + 20, y + 34, label, 16, 700, CHAT_TEXT3);
|
||
g += uiText(x + 20, y + 84, wert, 38, 800, CHAT_TEXT);
|
||
g += uiText(x + 20, y + h - 18, unten, 16, 500, CHAT_TEXT3);
|
||
return g;
|
||
}
|
||
|
||
function visAnmeldung() {
|
||
let g = "", y = 0;
|
||
g += uiText(0, y + 34, WEBINAR.titel, 34, 800, CHAT_TEXT);
|
||
g += uiText(0, y + 70, WEBINAR.untertitel, 19, 500, CHAT_TEXT2);
|
||
y += 102;
|
||
|
||
const luecke = 12, kb = (WB_B - 2 * luecke) / 3, kh = 136;
|
||
g += wbKpi(0, y, kb, kh, "Datum", WEBINAR.datum, WEBINAR.wochentag);
|
||
g += wbKpi(kb + luecke, y, kb, kh, "Uhrzeit", WEBINAR.uhrzeit, WEBINAR.dauer);
|
||
g += wbKpi(2 * (kb + luecke), y, kb, kh, "Ort", "Online", "im Browser");
|
||
y += kh + 30;
|
||
|
||
g += uiText(0, y, "Mit", 19, 800, CHAT_TEXT);
|
||
y += 18;
|
||
const sb = (WB_B - 16) / 2;
|
||
g += wbSprecher(0, y, sb, WB_SPRECHER[0], 0);
|
||
g += wbSprecher(sb + 16, y, sb, WB_SPRECHER[1], 0);
|
||
y += 84 + 30;
|
||
|
||
g += '<path d="' + rundRechteck(0, y, WB_B, 68, 12) + '" fill="#24242B"/>';
|
||
g += uiText(WB_B / 2, y + 42, "Jetzt anmelden", 22, 800, "#FFFFFF", { anker: "middle" });
|
||
y += 68 + 36;
|
||
g += uiText(WB_B / 2, y, "Link in der Bio", 18, 600, CHAT_TEXT3, { anker: "middle" });
|
||
y += 6;
|
||
|
||
return wbRahmen(WB_KOPF + y + WB_RAND, g, { zeichen: "calendar-check", titel: "Live-Webinar" });
|
||
}
|
||
|
||
/* ═══ 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. */
|
||
/* Die Kurven sind die von GSAP, mit dem die Website ihre Bewegungen macht --
|
||
als Formel und nicht als Bibliothek. Ein Verlauf ist eine Zeile Rechnung;
|
||
was GSAP darueber hinaus koennte (Zeitleisten, Motion-Path, ScrollTrigger),
|
||
braucht hier nichts: die Zeichenfunktion bekommt ihr t von aussen und ist
|
||
damit selbst schon die Zeitleiste. Die Namen stehen daneben, damit
|
||
nachvollziehbar bleibt, welche Kurve gemeint ist.
|
||
|
||
daempfen und rueck standen vorher unbenannt hier und sind Wort fuer Wort
|
||
GSAPs power2.inOut und back.out(1,70158) -- die Datei sprach die Sprache
|
||
also schon, sie sagte es nur nicht. */
|
||
const daempfen = x => x <= 0 ? 0 : x >= 1 ? 1 : x < .5 // power2.inOut
|
||
? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||
const rueck = x => x <= 0 ? 0 : x >= 1 ? 1 // back.out(1.7)
|
||
: 1 + 2.7 * Math.pow(x - 1, 3) + 1.7 * Math.pow(x - 1, 2);
|
||
/* Schnell los, lang aus. Alles, was ankommt und liegen bleibt -- ein Zeiger,
|
||
der ins Bild faehrt, eine Zeile, die einlaeuft -- gehoert hierher und
|
||
nicht auf eine Kurve, die auch langsam anfaengt. */
|
||
const raus = x => x <= 0 ? 0 : x >= 1 ? 1 // power2.out
|
||
: 1 - Math.pow(1 - x, 3);
|
||
/* Noch schaerfer: schnappt heraus und ist praktisch sofort fertig. Fuer den
|
||
Klickring, der einem Fingertipp folgt. */
|
||
const knall = x => x <= 0 ? 0 : x >= 1 ? 1 // expo.out
|
||
: 1 - Math.pow(2, -10 * x);
|
||
const stufe = (t, von, bis) => t <= von ? 0 : t >= bis ? 1 : (t - von) / (bis - von);
|
||
|
||
const VIS = {
|
||
segment: () => visSegment(),
|
||
vorlagen: () => visVorlagen(),
|
||
pruefung: () => visPruefung(),
|
||
mail: () => visMail(),
|
||
animMail: (j, t) => animMail(j, t),
|
||
anmeldung: () => visAnmeldung(),
|
||
animWebinar: (j, t) => animWebinar(j, t),
|
||
};
|
||
|
||
/* ─── 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;
|
||
/* Hell. Der Redaktionsplan sieht Hell und Dunkel im Wechsel vor, damit das
|
||
Profilraster atmet -- gewechselt wurde bisher nie, alle Beitraege stehen
|
||
auf Hell. Dieser auch: ein Editor-Canvas ist hell, und ein dunkler
|
||
Ausreisser unter vier hellen Kacheln ist kein Rhythmus, sondern ein
|
||
Ausrutscher. Der Wechsel gehoert bewusst geplant, nicht nebenbei. */
|
||
let variant = "hell", tilt = "an", superSample = 2, view = "streifen", tagOut = "an", frameStyle = "chamfer";
|
||
// Der Punktgrund ist die Voreinstellung dieses Beitrags, nicht der leere
|
||
// Grund: die Slides zeigen einen Editor-Canvas. Siehe CSS bei .layer-raster.
|
||
/* Text mittig. Die Visualisierungen sitzen ohnehin mittig -- 896 breit
|
||
bei x 92, also links wie rechts 92 px Rand -- und ein linksbuendiger
|
||
Textblock darueber steht dann sichtbar aus der Achse. */
|
||
let bandTon = "orange", rasterAn = "punkte", textMitte = "an";
|
||
|
||
/* ─── 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 = "post9";
|
||
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. */
|
||
/* Kein Band in diesem Beitrag: das durchgehende Element ist die Workflow-
|
||
Linie selbst, und zwei Klammern uebereinander sind eine zu viel. Die
|
||
Maschinerie bleibt stehen, laeuft aber leer -- ihre Stuetzpunkte in
|
||
BAND_BAHN sind ohnehin an Post 2 geeicht (rules.md 1a). */
|
||
const BAND_VON = 99, BAND_BIS = 99;
|
||
/* 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>' +
|
||
/* Ein leerer Fliesstext soll keinen leeren Absatz hinterlassen:
|
||
sonst schiebt sein Zeilenabstand die Visualisierung nach unten,
|
||
ohne dass etwas dasteht. Dieselbe Korrektur steht in
|
||
post3.html, dessen Titel-Slide ebenfalls ohne Fliesstext
|
||
auskommt. */
|
||
(s.body ? '<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);
|
||
|
||
/* Die Ueberschrift drei Pixel tiefer, wie auf den Startslides von Post 2
|
||
bis 6. Dort steckt der Wert im Projektstand als headline.dy, gesetzt von
|
||
applyEdits -- und da geht das Bewegtbild nicht durch, es baut sein Blatt
|
||
selbst. Seit es das erste Element im Carousel ist, faellt der
|
||
Unterschied auf: gemessen sassen die anderen bei y 95, dieses bei 92.
|
||
|
||
Direkt als transform und nicht ueber --ey: die Regel, die daraus eine
|
||
Verschiebung macht, haengt an [data-edit], und das Attribut setzt
|
||
tagEditable() -- eine weitere Stelle, die das Bewegtbild nicht
|
||
durchlaeuft. Mit --ey allein passierte gar nichts. */
|
||
const kopf = content.querySelector(".headline");
|
||
if (kopf) kopf.style.transform = "translateY(3px)";
|
||
|
||
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.
|
||
// Noahs Teamkarte gehoert zu Post 2 und kommt hier nicht vor.
|
||
/* 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");
|
||
/* no-cache und nicht no-store: der Browser darf seine Kopie behalten,
|
||
muss aber vor jeder Benutzung nachfragen. Eine ausgetauschte Datei
|
||
kommt damit genauso sofort an wie vorher, eine unveraenderte kostet
|
||
ein 304 ohne Rumpf statt ihrer vollen Groesse.
|
||
|
||
Mit no-store lud jeder Reload rund 6 MB neu -- 3 MB Screenshots,
|
||
3,3 MB Mockups -- obwohl sich nichts davon geaendert hatte. Gemessen
|
||
an 61 Anfragen je Aufruf. Die Auskuenfte (/bilder) und der Stand
|
||
bleiben bei no-store: die sind klein und duerfen nie alt sein. */
|
||
for (const path of [...new Set(paths)]) {
|
||
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;
|
||
}
|
||
|
||
|
||
/* ─── Umlaute im gezeichneten Text ───────────────────────────────────────
|
||
Auf einer Slide stand einmal "Sanitaer" und "Klima / Lueftung". Beides
|
||
kam woertlich aus der Quelle -- im Klickdummy war es dort schludrig
|
||
eingetippt --, und beides las sich im fertigen Beitrag wie ein Fehler,
|
||
weil es einer ist. Gefunden hat es niemand beim Bauen, sondern erst
|
||
jemand, der auf den fertigen Post geschaut hat.
|
||
|
||
Deshalb prueft es jetzt der Export. Ein reines Muster genuegt dafuer
|
||
nicht: Deutsch schreibt "neue", "Dauer", "Michael" voellig zu Recht mit
|
||
ae/oe/ue. Nachgezaehlt ueber den gezeichneten Text aller Projekte sind es
|
||
aber nur eine Handvoll solcher Woerter -- eine Ausnahmeliste ist also
|
||
kurz und trifft trotzdem alles andere.
|
||
|
||
ss ist der umgekehrte Fall: fast immer richtig (dass, Schluss, Adresse,
|
||
Netzanschluss), nur ein paar Woerter brauchen ein scharfes s. Die stehen
|
||
deshalb einzeln als Liste und nicht als Muster.
|
||
|
||
Kanalnamen sind Absicht: "# waermepumpe-mueller" ist ein Handle, und die
|
||
schreiben Chatwerkzeuge klein und ohne Umlaute. */
|
||
/* Woerter, in denen ae/oe/ue kein verkruepppelter Umlaut ist, sondern zwei
|
||
Laute an einer Silbengrenze. Die Eintraege wirken als Wortanfang.
|
||
|
||
Die Liste ist kurz geworden, weil die Regel darunter den groessten Teil
|
||
der Ausnahmen von selbst erledigt: ein ersatzweise geschriebener Umlaut
|
||
steht nie hinter einem Vokal. Vorher standen hier dauer, neue, steuer,
|
||
feuer, bauer -- alle nach demselben Muster, und die Liste waere mit
|
||
jedem Beitrag weitergewachsen. Was bleibt, sind die echten Sonderfaelle:
|
||
"Michael" (ae hinter h), "aktuell" (ue hinter t). */
|
||
const UMLAUT_ERLAUBT = new Set([
|
||
"michael", "aktuell", "museum",
|
||
"waermepumpe-mueller", "waermepumpe-abnahme",
|
||
]);
|
||
const BRAUCHT_SCHARFES_S = new Set([
|
||
"aufmass", "strasse", "groesse", "groesser", "fuss", "fussboden",
|
||
"mass", "masse", "weiss", "heisst", "aussen", "gross", "grosse", "spass",
|
||
]);
|
||
|
||
function umlautFehler(wurzel) {
|
||
const raus = [];
|
||
wurzel.querySelectorAll("text, .headline, .body, .badge").forEach(e => {
|
||
/* Nicht textContent, sondern die Textknoten mit Leerzeichen dazwischen.
|
||
Eine Ueberschrift ist "Formulare selbst bauen.<br><span>Per
|
||
Drag-and-drop.</span>" -- textContent klebt daraus "bauen.Per"
|
||
zusammen, ein Wort, das es nicht gibt. Ein Umbruch und eine
|
||
Elementgrenze sind Wortgrenzen. */
|
||
const stuecke = [];
|
||
const lauf = document.createTreeWalker(e, NodeFilter.SHOW_TEXT);
|
||
for (let k = lauf.nextNode(); k; k = lauf.nextNode()) stuecke.push(k.nodeValue);
|
||
const ganz = stuecke.join(" ");
|
||
/* Geviertstriche gehoeren nicht in unsere Texte. Der Halbgeviertstrich
|
||
als Gedankenstrich ist richtiges Deutsch und bleibt erlaubt; der
|
||
lange Strich ist eine englische Angewohnheit und liest sich auf einer
|
||
deutschen Slide fremd. Er kam nicht aus einer Quelle, sondern beim
|
||
Schreiben mit hinein. */
|
||
if (ganz.indexOf("\u2014") >= 0) {
|
||
const stelle = "Geviertstrich in „" + ganz.trim().slice(0, 40) + "“";
|
||
if (!raus.includes(stelle)) raus.push(stelle);
|
||
}
|
||
ganz.split(/[^A-Za-zÄÖÜäöüß0-9./#@-]+/).forEach(w => {
|
||
if (!w) return;
|
||
const k = w.toLowerCase().replace(/^[@#]+/, "").replace(/[.,;:!?)(]+$/, "");
|
||
if (!k || raus.includes(w)) return;
|
||
/* Mailadressen und Domains sind ausgenommen. Dort ist die
|
||
umlautfreie Schreibung keine Schludrigkeit, sondern Pflicht:
|
||
"buero@muster-shk.de" kann gar kein ue ersetzen. Ohne diese
|
||
Ausnahme meldete Post 8 drei Adressen als Fehler. */
|
||
if (/@/.test(k) || /\.(de|com|net|org|eu|at|ch|io)$/.test(k)) return;
|
||
if ([...UMLAUT_ERLAUBT].some(a => k.startsWith(a))) return;
|
||
/* ae/oe/ue nur dann als verunglueckter Umlaut, wenn kein Vokal
|
||
davorsteht. Ein ae, oe oder ue, das fuer ae, oe oder ue steht,
|
||
folgt immer einem Konsonanten oder steht am Wortanfang: "fuer",
|
||
"ueber", "gruen", "Sanitaer", "Lueftung". Hinter einem Vokal sind
|
||
es dagegen immer zwei eigene Laute: bauen, Bauer, neue, Dauer,
|
||
Steuer, Feuer, Mauer, genauer. Die Regel ersetzt sechs Eintraege
|
||
in UMLAUT_ERLAUBT und alle, die dort noch gelandet waeren. */
|
||
if (/(^|[^aeiou])(ae|oe|ue)/.test(k) || BRAUCHT_SCHARFES_S.has(k)) raus.push(w);
|
||
});
|
||
});
|
||
return raus;
|
||
}
|
||
|
||
/* Fuer animation.mjs: das Bewegtbild laeuft nicht durch renderSlide und
|
||
braucht deshalb eine eigene Tuer zu derselben Pruefung. */
|
||
window.TEXT_PRUEFUNG = () => umlautFehler(document);
|
||
|
||
async function renderSlide(i) {
|
||
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
|
||
// stehen, landeten sie im Bild.
|
||
clearTimeout(nudgeTimer); hideGuides();
|
||
const holder = deck.querySelectorAll(".holder")[i];
|
||
const el = holder.querySelector(".slide");
|
||
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
|
||
// Rendern muss der Holder kurz sichtbar sein.
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
// Auswahlrahmen und Hover-Kontur sind Werkzeug, kein Inhalt: für den Export
|
||
// abschalten, sonst wird die gelbe Umrandung mitgerendert.
|
||
const modeBefore = el.dataset.editmode;
|
||
el.dataset.editmode = "aus";
|
||
try {
|
||
await document.fonts.ready;
|
||
/* Den Text pruefen, bevor gerendert wird. Lieber ein Abbruch mit
|
||
klarer Meldung als ein fertiges PNG mit "Sanitaer" darauf. */
|
||
const uf = umlautFehler(el);
|
||
if (uf.length) throw new Error("Slide " + (i + 1) + ": " + uf.join(", ") +
|
||
". Fehlt ein Umlaut, schreib ihn; ist das Wort so richtig, gehoert es " +
|
||
"in UMLAUT_ERLAUBT. Einen Geviertstrich ersetzt ein Punkt oder ein Komma.");
|
||
const imgs = [...el.querySelectorAll("img")];
|
||
await Promise.all(imgs.map(im =>
|
||
im.complete ? im.decode().catch(() => {})
|
||
: new Promise(r => { im.onload = r; im.onerror = r; })));
|
||
const broken = imgs.filter(im => !im.naturalWidth);
|
||
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
|
||
|
||
/* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
|
||
mit dem aus der Vorlage geeichten Erwartungswert. */
|
||
const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
|
||
// Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
|
||
const rahmen = c.tagName === "IMG";
|
||
const bild = rahmen ? c : c.querySelector("img");
|
||
if (!bild) return null;
|
||
const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
|
||
const qImg = quadInSlide(el, bild);
|
||
if (!qBox || !qImg) return null;
|
||
return {
|
||
name: c.className || c.tagName.toLowerCase(),
|
||
pts: ueberlappPunkte(qBox, qImg),
|
||
rahmen,
|
||
erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
|
||
/* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
|
||
das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
|
||
— das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
|
||
Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
|
||
obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
|
||
aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
|
||
kleiner rechnen kann jedes Zielsystem selbst. */
|
||
const canvas = await htmlToImage.toCanvas(el, renderOptions());
|
||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||
// Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
|
||
// auf die tatsächliche Ausgabegröße umgerechnet.
|
||
const AW = canvas.width, AH = canvas.height, af = AW / 1080;
|
||
const px = ctx.getImageData(0, 0, AW, AH).data;
|
||
const lum = (x, y) => {
|
||
const sx = Math.round(x * af), sy = Math.round(y * af);
|
||
if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
|
||
const j = (sy * AW + sx) * 4;
|
||
return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
|
||
};
|
||
|
||
const befund = ziele.map(z => {
|
||
const spanne = spanneAn(lum, z.pts);
|
||
// Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
|
||
let fehlt = spanne <= LEER_SPANNE;
|
||
let dunkel = null;
|
||
// Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
|
||
// der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
|
||
// ankommt, gemessen gegen die Vorlage.
|
||
if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
|
||
dunkel = dunkelAn(lum, z.pts);
|
||
if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
|
||
}
|
||
return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
|
||
});
|
||
const fehlend = befund.filter(b => b.fehlt);
|
||
|
||
if (!fehlend.length) {
|
||
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
|
||
if (blob) return blob;
|
||
}
|
||
if (attempt < MAX_TRIES) {
|
||
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
|
||
befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
|
||
(b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
|
||
" erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
|
||
setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
|
||
" fehlte, Versuch " + (attempt + 1) + " ...");
|
||
await new Promise(r => setTimeout(r, 180 * attempt));
|
||
}
|
||
}
|
||
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
|
||
" Versuchen unvollstaendig. Details in der Konsole.");
|
||
} finally {
|
||
el.dataset.editmode = modeBefore;
|
||
holder.style.display = restore;
|
||
}
|
||
}
|
||
|
||
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
|
||
// sichtbar ist, was tatsächlich auf der Platte landet.
|
||
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
|
||
|
||
async function downloadOne() {
|
||
setStatus("Rendere Slide " + (current + 1) + " …");
|
||
downloadBlob(await renderSlide(current), fileName(current));
|
||
setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
async function downloadAll() {
|
||
const zip = new JSZip();
|
||
for (let i = 0; i < SLIDES.length; i++) {
|
||
setStatus("Rendere Slide " + (i + 1) + " von " + SLIDES.length + " …");
|
||
zip.file(fileName(i), await renderSlide(i));
|
||
}
|
||
show(current);
|
||
setStatus("Packe ZIP …");
|
||
/* Der Name kommt aus PROJECT und nicht aus einer festen Zeichenkette.
|
||
Vorher stand hier "hivegrid-post1-loesung" -- in allen Projektdateien,
|
||
weil sie voneinander kopiert wurden. Post 2 und Post 3 haben ihre ZIPs
|
||
damit unter dem Namen von Post 1 abgelegt, und im Downloads-Ordner lagen
|
||
drei verschiedene Beitraege unter einem Namen. */
|
||
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
|
||
"hivegrid-" + PROJECT + "-" + variant + ".zip");
|
||
setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
const guard = fn => () => fn().catch(err => {
|
||
console.error(err);
|
||
setStatus("Fehler: " + err.message, "err");
|
||
});
|
||
|
||
/* ─── Slides verwalten ────────────────────────────────────────────────────
|
||
Die Felder pro Slide (Verschiebungen, Muster, Layout) haengen an der
|
||
Anzahl und muessen bei jeder Aenderung im Gleichschritt mitwandern. Das
|
||
laeuft ueber slideListe als Bezugsgroesse, nicht ueber SLIDES: SLIDES
|
||
wird daraus jedes Mal neu berechnet. */
|
||
function passeFelderAn() {
|
||
const n = SLIDES.length;
|
||
while (edits.length < n) edits.push({});
|
||
edits.length = n;
|
||
while (bgState.length < n) bgState.push({ bg: "formen", int: "dezent" });
|
||
bgState.length = n;
|
||
while (layoutState.length < n) layoutState.push(SLIDES[layoutState.length].layouts[0].id);
|
||
layoutState.length = n;
|
||
// Ein Layout, das es auf dieser Slide nicht gibt, faellt auf das erste
|
||
// zurueck: sonst zeigt eine Feature-Slide ein Titel-Layout.
|
||
layoutState.forEach((id, i) => {
|
||
if (!SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = SLIDES[i].layouts[0].id;
|
||
});
|
||
}
|
||
|
||
function deckNeuBauen() {
|
||
deck.innerHTML = "";
|
||
nav.innerHTML = "";
|
||
buildDeck();
|
||
applyAll();
|
||
applyEditsAll();
|
||
}
|
||
|
||
function anzahlNachziehen() {
|
||
const n = SLIDES.length;
|
||
const kopf = document.getElementById("kopfzeile");
|
||
if (kopf) kopf.textContent =
|
||
(n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × 1350 px";
|
||
const zip = document.getElementById("dl-all");
|
||
if (zip) {
|
||
zip.textContent = "Alle " + n + " Slides als ZIP";
|
||
// Bei einer einzigen Slide ist ein ZIP nur ein Umweg.
|
||
zip.style.display = n > 1 ? "" : "none";
|
||
}
|
||
// Das durchgehende Blatt ist so breit wie der Post lang ist.
|
||
deck.style.setProperty("--sheet-w", (n * 1080) + "px");
|
||
}
|
||
|
||
function nachDeckAenderung(an, meldung) {
|
||
slidesNeuAufbauen();
|
||
passeFelderAn();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
current = Math.max(0, Math.min(an, SLIDES.length - 1));
|
||
show(current);
|
||
anzahlNachziehen();
|
||
syncInhalt();
|
||
syncEditPanel();
|
||
saveState();
|
||
if (meldung) setStatus(meldung, "ok");
|
||
}
|
||
|
||
function slideEinfuegen(eintrag, an) {
|
||
slideListe.splice(an, 0, eintrag);
|
||
edits.splice(an, 0, {});
|
||
bgState.splice(an, 0, kopie(bgState[current] || { bg: "formen", int: "dezent" }));
|
||
slidesNeuAufbauen();
|
||
layoutState.splice(an, 0, SLIDES[an].layouts[0].id);
|
||
}
|
||
|
||
function slideNeu() {
|
||
const an = current + 1;
|
||
slideEinfuegen({ werte: {} }, an);
|
||
nachDeckAenderung(an, "Slide " + (an + 1) + " angelegt");
|
||
}
|
||
|
||
function slideKopieren() {
|
||
const an = current + 1;
|
||
const q = slideListe[current];
|
||
slideEinfuegen({ ref: q.ref, key: q.key, werte: kopie(q.werte || {}) }, an);
|
||
// Auch die Feinarbeit mitnehmen, sonst ist die Kopie nur halb eine.
|
||
edits[an] = kopie(edits[current]);
|
||
bgState[an] = kopie(bgState[current]);
|
||
layoutState[an] = layoutState[current];
|
||
nachDeckAenderung(an, "Slide kopiert");
|
||
}
|
||
|
||
function slideLoeschen() {
|
||
if (SLIDES.length <= 1) { setStatus("Die letzte Slide bleibt stehen", "err"); return; }
|
||
const weg = current;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => a.splice(weg, 1));
|
||
nachDeckAenderung(Math.min(weg, SLIDES.length - 2), "Slide gelöscht");
|
||
}
|
||
|
||
function slideSchieben(um) {
|
||
const ziel = current + um;
|
||
if (ziel < 0 || ziel >= SLIDES.length) return;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => {
|
||
const [x] = a.splice(current, 1);
|
||
a.splice(ziel, 0, x);
|
||
});
|
||
nachDeckAenderung(ziel, "Slide verschoben");
|
||
}
|
||
|
||
/* ─── Inhalt der gewaehlten Slide ─────────────────────────────────────────
|
||
Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht
|
||
aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt
|
||
Aenderungen an der Vorlage im Code weiterhin mit. */
|
||
/* ─── Eigene Bilder ───────────────────────────────────────────────────────
|
||
Die Datei geht unveraendert an den Server und liegt danach unter
|
||
assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere
|
||
ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff
|
||
unbrauchbar. So steht dort nur der Pfad. */
|
||
function bildOptionen() {
|
||
return BILDER.map(b =>
|
||
'<option value="' + b.pfad + '">' +
|
||
(b.ordner === "eigene" ? "★ " : "") + b.name + '</option>').join("");
|
||
}
|
||
|
||
async function bilderNachladen() {
|
||
try {
|
||
const res = await fetch("/bilder", { cache: "no-store" });
|
||
if (!res.ok) return;
|
||
const liste = await res.json();
|
||
if (Array.isArray(liste) && liste.length) BILDER = liste;
|
||
} catch (_) { /* ohne Server bleibt die eingebaute Liste */ }
|
||
}
|
||
|
||
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
|
||
async function bildBereitstellen(pfad) {
|
||
if (assets[pfad]) return true;
|
||
const res = await fetch(pfad, { cache: "no-cache" }).catch(() => null);
|
||
if (!res || !res.ok) return false;
|
||
const blob = await res.blob();
|
||
assets[pfad] = await new Promise((ok, fail) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => ok(fr.result);
|
||
fr.onerror = fail;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async function bildImportieren(datei) {
|
||
const res = await fetch("/upload", {
|
||
method: "POST",
|
||
// Kodiert, damit Umlaute und Leerzeichen den Header nicht sprengen.
|
||
headers: { "X-Filename": encodeURIComponent(datei.name) },
|
||
body: datei,
|
||
});
|
||
if (!res.ok) {
|
||
const w = await res.json().catch(() => ({}));
|
||
/* 404 heisst hier fast immer: der Python-Server laeuft noch in einer
|
||
Fassung ohne /upload. Die Seite neu zu laden hilft dagegen nicht --
|
||
serve.py wird nur beim Start eingelesen. */
|
||
if (res.status === 404) throw new Error("Server kennt /upload nicht · ./serve.sh neu starten");
|
||
throw new Error(w.error || ("HTTP " + res.status));
|
||
}
|
||
const { pfad } = await res.json();
|
||
await bilderNachladen();
|
||
await bildBereitstellen(pfad);
|
||
return pfad;
|
||
}
|
||
|
||
// Masse aus dem Bild selbst: ein Hochformat soll nicht in einem
|
||
// Querformat-Rahmen stehen.
|
||
function seitenverhaeltnis(pfad) {
|
||
return new Promise(ok => {
|
||
const im = new Image();
|
||
im.onload = () => ok(im.naturalWidth / im.naturalHeight);
|
||
im.onerror = () => ok(1.6);
|
||
im.src = assets[pfad] || pfad;
|
||
});
|
||
}
|
||
|
||
/* Ein Element genau auf die Mitte der Slide setzen. Gerechnet wird nicht,
|
||
sondern gemessen: das Blatt ist gepolstert, die Mockups stehen schief im
|
||
Raum, und ein Dreisatz ueber left und bottom laege daneben. Der Versatz
|
||
wandert in die Bearbeitung, ist also nachtraeglich frei verschiebbar. */
|
||
function mittigSetzen(i, key) {
|
||
const node = deck.querySelector(
|
||
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
|
||
if (!node) return;
|
||
const slide = node.closest(".slide");
|
||
if (!slide) return;
|
||
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
|
||
if (!sr.width || !nr.width) return;
|
||
const k = sr.width / 1080;
|
||
const dx = ((nr.left + nr.width / 2) - (sr.left + sr.width / 2)) / k;
|
||
const dy = ((nr.top + nr.height / 2) - (sr.top + sr.height / 2)) / k;
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
set.dx = Math.round((set.dx || 0) - dx);
|
||
set.dy = Math.round((set.dy || 0) - dy);
|
||
applyEditsAll();
|
||
}
|
||
|
||
async function alsZweitesBild(i, pfad, mittig) {
|
||
const ar = await seitenverhaeltnis(pfad);
|
||
const w = werteVon(i);
|
||
const vorher = w.media2 || SLIDES[i].media2 || {};
|
||
w.media2 = {
|
||
img: pfad, w: vorher.w || 480, ar: +ar.toFixed(4),
|
||
left: vorher.left != null ? vorher.left : 300,
|
||
bottom: vorher.bottom != null ? vorher.bottom : 400,
|
||
z: vorher.z || 2, rot: mittig ? 0 : (vorher.rot || 0),
|
||
};
|
||
slidesNeuAufbauen();
|
||
deckNeuBauen();
|
||
show(i);
|
||
// Ein frisch importiertes Bild soll man sehen, nicht suchen.
|
||
if (mittig) {
|
||
edits[i]["mock-2"] = {};
|
||
mittigSetzen(i, "mock-2");
|
||
pick(i, "mock-2");
|
||
}
|
||
saveState();
|
||
}
|
||
|
||
async function dateienAufnehmen(dateien, zielSlide) {
|
||
const bilder = [...dateien].filter(f => /^image\//.test(f.type));
|
||
if (!bilder.length) { setStatus("Keine Bilddatei dabei", "err"); return; }
|
||
setStatus("Importiere " + bilder.length + " Bild(er) …");
|
||
const pfade = [];
|
||
try {
|
||
for (const f of bilder) pfade.push(await bildImportieren(f));
|
||
} catch (err) {
|
||
setStatus("Import fehlgeschlagen: " + err.message, "err");
|
||
return;
|
||
}
|
||
// Beim Ablegen auf einer Slide wandert das erste Bild gleich dorthin. Ueber
|
||
// den Knopf importiert man nur; zugeordnet wird dann in den Feldern.
|
||
/* Ohne Ziel gilt die gewaehlte Slide. Frueher wurde nur importiert und
|
||
die Zuordnung blieb in einem Auswahlfeld liegen -- das Bild war damit
|
||
nirgends zu sehen und der Import wirkte wie ein Fehlschlag. */
|
||
const ziel = typeof zielSlide === "number" ? zielSlide : current;
|
||
await alsZweitesBild(ziel, pfade[0], true);
|
||
syncInhalt();
|
||
setStatus(pfade.length === 1
|
||
? "Bild auf Slide " + (ziel + 1) + " gelegt · mit E verschieben"
|
||
: pfade.length + " Bilder importiert, erstes auf Slide " + (ziel + 1) +
|
||
" gelegt · Rest unter Inhalt", "ok");
|
||
}
|
||
|
||
document.getElementById("bild-waehlen").addEventListener("click",
|
||
() => document.getElementById("bild-datei").click());
|
||
document.getElementById("bild-datei").addEventListener("change", ev => {
|
||
const f = ev.target.files;
|
||
if (f && f.length) dateienAufnehmen(f);
|
||
ev.target.value = ""; // dieselbe Datei soll erneut waehlbar bleiben
|
||
});
|
||
|
||
/* Ablegen auf einer Slide. Der Browser oeffnet eine fallengelassene Datei
|
||
sonst einfach im Tab und der Projektstand waere verloren. */
|
||
(function ablegen() {
|
||
const sc = document.getElementById("scroller");
|
||
const zielAus = e => {
|
||
const h = e.target.closest && e.target.closest(".holder");
|
||
return h ? Number(h.dataset.n) : null;
|
||
};
|
||
["dragenter", "dragover"].forEach(art => sc.addEventListener(art, e => {
|
||
if (!e.dataTransfer || [...e.dataTransfer.types].indexOf("Files") < 0) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
const n = zielAus(e);
|
||
deck.querySelectorAll(".holder").forEach((h, i) => h.classList.toggle("ablegen", i === n));
|
||
}));
|
||
sc.addEventListener("dragleave", e => {
|
||
if (e.relatedTarget && sc.contains(e.relatedTarget)) return;
|
||
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
|
||
});
|
||
sc.addEventListener("drop", e => {
|
||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||
e.preventDefault();
|
||
const n = zielAus(e);
|
||
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
|
||
dateienAufnehmen(e.dataTransfer.files, n == null ? undefined : n);
|
||
});
|
||
// Ausserhalb der Flaeche nichts oeffnen, sondern still ablehnen.
|
||
["dragover", "drop"].forEach(art => window.addEventListener(art, e => {
|
||
if (!sc.contains(e.target)) e.preventDefault();
|
||
}));
|
||
})();
|
||
|
||
function werteVon(i) {
|
||
const e = slideListe[i];
|
||
return (e.werte = e.werte || {});
|
||
}
|
||
|
||
function syncInhalt() {
|
||
const box = document.getElementById("inhalt");
|
||
if (!box) return;
|
||
const s = SLIDES[current];
|
||
const id = current + ":" + (s.type || "");
|
||
if (box.dataset.for !== id) {
|
||
box.dataset.for = id;
|
||
const felder = s.type === "feature"
|
||
? [["eyebrow", "Tag", "input"], ["head", "Überschrift", "area"], ["body", "Fließtext", "area"]]
|
||
: [["head", "Überschrift", "area"], ["sub", "Claim", "area"]];
|
||
box.innerHTML = felder.map(([k, label, art]) =>
|
||
'<label class="inh-label" for="in-' + k + '">' + label + '</label>' +
|
||
(art === "area"
|
||
? '<textarea id="in-' + k + '" data-feld="' + k + '" rows="2"></textarea>'
|
||
: '<input id="in-' + k + '" data-feld="' + k + '" type="text">')
|
||
).join("") +
|
||
(s.type === "feature"
|
||
? '<label class="inh-label" for="in-img">Screenshot</label>' +
|
||
'<select id="in-img" data-feld="img">' + bildOptionen() + '</select>' +
|
||
'<label class="inh-label" for="in-media2">Zweites Bild</label>' +
|
||
'<select id="in-media2" data-feld="media2">' +
|
||
'<option value="">— keins —</option>' + bildOptionen() + '</select>'
|
||
: "") +
|
||
'<div class="hint">*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' +
|
||
(s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '</div>';
|
||
}
|
||
box.querySelectorAll("[data-feld]").forEach(el => {
|
||
if (document.activeElement === el) return;
|
||
const k = el.dataset.feld;
|
||
if (k === "img") el.value = s.img || "";
|
||
else if (k === "media2") el.value = (s.media2 && s.media2.img) || "";
|
||
else el.value = htmlZuText(s[k]);
|
||
});
|
||
}
|
||
|
||
document.getElementById("inhalt").addEventListener("input", ev => {
|
||
const el = ev.target.closest("[data-feld]");
|
||
if (!el) return;
|
||
const k = el.dataset.feld;
|
||
const w = werteVon(current);
|
||
if (k === "media2") {
|
||
if (!el.value) {
|
||
// null statt weglassen: sonst kaeme das Bild der Vorlage zurueck.
|
||
w.media2 = null;
|
||
slidesNeuAufbauen(); deckNeuBauen(); show(current); saveState();
|
||
} else {
|
||
alsZweitesBild(current, el.value);
|
||
}
|
||
return;
|
||
}
|
||
if (k === "img") {
|
||
w.img = el.value;
|
||
// Das Rahmenformat folgt dem Bild, sonst steht ein Hochformat in einem
|
||
// Querformat-Rahmen und laesst links und rechts Flaechen frei.
|
||
const im = new Image();
|
||
im.onload = () => {
|
||
w.mockAR = +(im.naturalWidth / im.naturalHeight).toFixed(4);
|
||
w.imgScale = 1; w.imgX = "0%"; w.imgY = "0%";
|
||
slidesNeuAufbauen();
|
||
// Das Rahmenformat steckt im Aufbau der Seite, nicht in ihren Texten:
|
||
// dafuer reicht rebuildPage nicht, der Halter muss neu entstehen.
|
||
deckNeuBauen();
|
||
show(current);
|
||
saveState();
|
||
};
|
||
im.src = assets[el.value] || el.value;
|
||
return;
|
||
}
|
||
w[k] = textZuHtml(el.value);
|
||
slidesNeuAufbauen();
|
||
// Nur die betroffene Seite neu zeichnen: ein voller Neuaufbau wuerde das
|
||
// Eingabefeld unter den Fingern wegreissen.
|
||
rebuildPage(current);
|
||
applyEditsAll();
|
||
nav.querySelectorAll("button")[current].innerHTML =
|
||
'<span class="num">' + (current + 1) + '</span>' +
|
||
(SLIDES[current].type === "title" ? "Titel" : SLIDES[current].eyebrow);
|
||
saveState();
|
||
});
|
||
|
||
document.getElementById("slide-ops").addEventListener("click", ev => {
|
||
const b = ev.target.closest("button");
|
||
if (!b || stateLocked) return;
|
||
if (b.id === "slide-neu") slideNeu();
|
||
else if (b.id === "slide-kopie") slideKopieren();
|
||
else if (b.id === "slide-weg") slideLoeschen();
|
||
else if (b.id === "slide-links") slideSchieben(-1);
|
||
else if (b.id === "slide-rechts") slideSchieben(1);
|
||
});
|
||
|
||
function buildDeck() {
|
||
SLIDES.forEach((sl, i) => {
|
||
const holder = document.createElement("div");
|
||
holder.className = "holder";
|
||
holder.dataset.n = String(i);
|
||
holder.appendChild(buildSlide(sl, i));
|
||
deck.appendChild(holder);
|
||
});
|
||
SLIDES.forEach((sl, i) => {
|
||
const b = document.createElement("button");
|
||
b.dataset.n = String(i);
|
||
b.setAttribute("aria-pressed", String(i === 0));
|
||
b.innerHTML = '<span class="num">' + (i + 1) + '</span>' +
|
||
(sl.type === "title" ? "Titel" : sl.eyebrow);
|
||
nav.appendChild(b);
|
||
});
|
||
}
|
||
|
||
deck.addEventListener("click", e => {
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
const h = e.target.closest(".holder");
|
||
if (h) show(Number(h.dataset.n));
|
||
});
|
||
|
||
/* Ziehen: Die Vorschau ist skaliert, die Slide nicht. Mausdeltas werden
|
||
deshalb durch den Maßstab geteilt, sonst wandert das Element bei kleiner
|
||
Vorschau um ein Vielfaches. */
|
||
deck.addEventListener("mousedown", e => {
|
||
if (editMode !== "an") return;
|
||
const node = e.target.closest("[data-edit]");
|
||
if (!node) return;
|
||
const holder = node.closest(".holder");
|
||
const page = node.closest(".page");
|
||
const idx = Number(page.dataset.j); // Besitzer-Seite, nicht die angeklickte Slide
|
||
const key = node.dataset.edit;
|
||
e.preventDefault();
|
||
|
||
show(Number(holder.dataset.n));
|
||
pick(idx, key);
|
||
|
||
/* Den Maßstab aus dem lesen, was tatsächlich gezeichnet ist. Das Rechteck
|
||
zu messen geht nicht: .strip ist ein Flex-Kind der schon skalierten
|
||
Hülle, seine Border-Box misst Breite mal Maßstab hoch zwei. Und
|
||
autoFit() neu zu rechnen geht seit dem Element-Feld auch nicht mehr:
|
||
das fährt beim Anklicken ein, macht die Bühne schmaler und ändert den
|
||
Maßstab mitten im Klick. */
|
||
const mSc = /scale\(([-\d.]+)\)/.exec(deck.style.transform || "");
|
||
const k = mSc ? Number(mSc[1]) : (zoom || autoFit());
|
||
const set = (edits[idx][key] = edits[idx][key] || {});
|
||
const startX = e.clientX, startY = e.clientY;
|
||
const baseX = set.dx || 0, baseY = set.dy || 0;
|
||
node.style.cursor = "grabbing";
|
||
showGuides(holder.querySelector(".slide"));
|
||
updateGuides(node);
|
||
|
||
const move = ev => {
|
||
set.dx = Math.round(baseX + (ev.clientX - startX) / k);
|
||
set.dy = Math.round(baseY + (ev.clientY - startY) / k);
|
||
applyEditsAll(); // zieht alle Kopien der Seite mit
|
||
updateGuides(node);
|
||
syncEditPanel();
|
||
};
|
||
const up = () => {
|
||
node.style.cursor = "";
|
||
hideGuides();
|
||
document.removeEventListener("mousemove", move);
|
||
document.removeEventListener("mouseup", up);
|
||
saveState();
|
||
};
|
||
document.addEventListener("mousemove", move);
|
||
document.addEventListener("mouseup", up);
|
||
});
|
||
|
||
function pick(idx, key) {
|
||
picked = { slide: idx, key };
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
deck.querySelectorAll('.page[data-j="' + idx + '"] [data-edit="' + key + '"]')
|
||
.forEach(n => n.classList.add("picked"));
|
||
syncEditPanel();
|
||
}
|
||
|
||
let nudgeTimer = null;
|
||
function nudge(dx, dy) {
|
||
if (!picked) return;
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
set.dx = (set.dx || 0) + dx;
|
||
set.dy = (set.dy || 0) + dy;
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
// Pixelweise schieben braucht dieselbe Rueckmeldung wie das Ziehen, nur
|
||
// soll sie danach von selbst verschwinden.
|
||
const holder = deck.querySelectorAll(".holder")[current];
|
||
const node = holder && holder.querySelector('.page[data-j="' + picked.slide +
|
||
'"] [data-edit="' + picked.key + '"]');
|
||
if (node) {
|
||
showGuides(holder.querySelector(".slide"));
|
||
updateGuides(node);
|
||
clearTimeout(nudgeTimer);
|
||
nudgeTimer = setTimeout(hideGuides, 900);
|
||
}
|
||
saveState();
|
||
}
|
||
|
||
function resize(dir) {
|
||
if (!picked) return;
|
||
const std = STANDARD[picked.key];
|
||
if (!std || !std.prop) return;
|
||
const base = stdSize(picked.key, picked.slide);
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
set.size = Math.max(8, (set.size || base) + dir * stepFor(picked.key, picked.slide));
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
saveState();
|
||
}
|
||
|
||
function resetOne() {
|
||
if (!picked) return;
|
||
delete edits[picked.slide][picked.key];
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
saveState();
|
||
}
|
||
|
||
function resetAll() {
|
||
edits = SLIDES.map(() => ({}));
|
||
applyEditsAll();
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
saveState();
|
||
setStatus("Alle Slides auf Standard zurückgesetzt", "ok");
|
||
}
|
||
|
||
/* X und Y zaehlen ab der Mitte der Slide, nicht ab der Stelle, an der das
|
||
Element von sich aus laege. Nur so bedeuten gleiche Werte bei zwei
|
||
verschiedenen Elementen auch dieselbe Stelle.
|
||
Gemessen wird die OBERE LINKE ECKE, nicht der Mittelpunkt. Eine
|
||
zweizeilige und eine dreizeilige Ueberschrift sind unterschiedlich hoch;
|
||
bei gleichem Mittelpunkt liegen ihre Oberkanten 39 px auseinander und der
|
||
hoehere Block waechst nach oben ins Tag. Ab der ersten Zeile gerechnet
|
||
stimmt es unabhaengig von der Zeilenzahl.
|
||
Gemessen wird die erste sichtbare Kopie: in der Einzelansicht sind die
|
||
uebrigen Holder ausgeblendet und liefern ein leeres Rechteck. */
|
||
function centreOf(slideIdx, key) {
|
||
const nodes = deck.querySelectorAll(
|
||
'.page[data-j="' + slideIdx + '"] [data-edit="' + key + '"]');
|
||
for (const n of nodes) {
|
||
const r = n.getBoundingClientRect();
|
||
if (!r.width) continue;
|
||
const holder = n.closest(".slide");
|
||
const sr = holder && holder.getBoundingClientRect();
|
||
if (!sr || !sr.width) continue;
|
||
const k = sr.width / 1080;
|
||
return {
|
||
x: Math.round((r.left - sr.left) / k - 540),
|
||
y: Math.round((r.top - sr.top) / k - 675),
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* Welches Textfeld der Slide haengt an welchem Element. Nur was hier steht,
|
||
laesst sich im Element-Feld direkt beschriften; der Rest (Logos, Bilder,
|
||
Geraete) traegt keinen Text. */
|
||
const TEXT_ZU_FELD = {
|
||
"badge": "eyebrow", "headline": "head", "body": "body",
|
||
"title-head": "head", "title-sub": "sub", "wabe-claim": "sub",
|
||
};
|
||
|
||
/* Ein geloeschtes Element laesst sich nicht mehr anklicken. Ohne diese
|
||
Liste bliebe nur "Alles auf Standard" oder Rueckgaengig -- es waere also
|
||
praktisch verloren. */
|
||
/* Der Index kommt von aussen: seit jede Slide das ganze Blatt traegt, ist
|
||
die angezeigte Slide nicht mehr zwingend die bearbeitete. */
|
||
function verstecktHtml(i) {
|
||
const versteckt = Object.keys(edits[i] || {})
|
||
.filter(k => (edits[i][k] || {}).weg);
|
||
if (!versteckt.length) return "";
|
||
return '<div class="abschnitt">Gelöscht auf Slide ' + (i + 1) + '</div>' +
|
||
'<div class="opts">' +
|
||
versteckt.map(k => '<button data-zurueck="' + k + '" data-slide="' + i + '">' +
|
||
((STANDARD[k] || {}).label || k) + ' zurück</button>').join("") + '</div>';
|
||
}
|
||
|
||
/* Ausrichten an der Slide. Gemessen statt gerechnet: die Mockups stehen
|
||
perspektivisch im Raum und das Blatt ist gepolstert, ein Dreisatz ueber
|
||
left und top laege daneben. 92 px ist der Rand, den die Gestaltung
|
||
ohnehin haelt (.content). */
|
||
const RAND = 92;
|
||
function ausrichten(i, key, waag, senk) {
|
||
const node = deck.querySelector(
|
||
'.page[data-j="' + i + '"] [data-edit="' + key + '"]');
|
||
const slide = node && node.closest(".slide");
|
||
if (!slide) return;
|
||
const sr = slide.getBoundingClientRect(), nr = node.getBoundingClientRect();
|
||
if (!sr.width || !nr.width) return;
|
||
const k = sr.width / 1080;
|
||
const l = (nr.left - sr.left) / k, o = (nr.top - sr.top) / k;
|
||
const b = nr.width / k, h = nr.height / k;
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
if (waag) {
|
||
const ziel = waag === "links" ? RAND
|
||
: waag === "mitte" ? (1080 - b) / 2
|
||
: 1080 - RAND - b;
|
||
set.dx = Math.round((set.dx || 0) + (ziel - l));
|
||
}
|
||
if (senk) {
|
||
const ziel = senk === "oben" ? RAND
|
||
: senk === "mitte" ? (1350 - h) / 2
|
||
: 1350 - RAND - h;
|
||
set.dy = Math.round((set.dy || 0) + (ziel - o));
|
||
}
|
||
applyEditsAll();
|
||
syncEditPanel();
|
||
saveState();
|
||
}
|
||
|
||
function syncEditPanel() {
|
||
const koerper = document.getElementById("el-inhalt");
|
||
const titel = document.getElementById("el-titel");
|
||
// Nur beim Auf- und Zugehen neu einpassen: sonst springt der Streifen bei
|
||
// jedem Wechsel der Auswahl.
|
||
const vorher = document.body.dataset.elem;
|
||
document.body.dataset.elem = picked ? "an" : "aus";
|
||
const wechsel = vorher !== document.body.dataset.elem;
|
||
if (!picked) { koerper.dataset.for = ""; if (wechsel) fitStrip(); return; }
|
||
|
||
const std = STANDARD[picked.key] || {};
|
||
const e = (edits[picked.slide] || {})[picked.key] || {};
|
||
const base = stdSize(picked.key, picked.slide);
|
||
const feld = TEXT_ZU_FELD[picked.key];
|
||
const id = picked.slide + ":" + picked.key + ":" + (edits[picked.slide][picked.key] || {}).weg;
|
||
// Nur bei einer anderen Auswahl neu aufbauen. Beim Ziehen laeuft das hier
|
||
// pro Mausbewegung; ein Neuaufbau wuerde die Eingabe unter den Fingern
|
||
// wegreissen.
|
||
if (koerper.dataset.for !== id) {
|
||
koerper.dataset.for = id;
|
||
titel.textContent = (std.label || picked.key);
|
||
koerper.innerHTML =
|
||
'<p class="sub">Slide ' + (picked.slide + 1) + " von " + SLIDES.length + '</p>' +
|
||
(feld
|
||
? '<div class="abschnitt">Text</div>' +
|
||
'<textarea id="f-text" rows="3"></textarea>' +
|
||
'<div class="hint">*Wort* setzt den Akzent, Zeilenumbruch bricht um.</div>'
|
||
: "") +
|
||
'<div class="abschnitt">Maße</div>' +
|
||
'<div class="fields">' +
|
||
(std.prop
|
||
? '<label for="f-size">' + (std.prop === "font-size" ? "Schrift" : "Breite") +
|
||
'</label><input id="f-size" type="number" step="1" style="grid-column:2/5">'
|
||
: "") +
|
||
'<label for="f-dx">X</label><input id="f-dx" type="number" step="1">' +
|
||
'<label for="f-dy">Y</label><input id="f-dy" type="number" step="1">' +
|
||
'<label for="f-z">Ebene</label>' +
|
||
'<input id="f-z" type="number" step="1" placeholder="Standard" style="grid-column:2/5">' +
|
||
'</div>' +
|
||
'<div class="hint">' + (std.prop ? "Standard " + base + " px · " : "") +
|
||
'Obere linke Ecke, 0 / 0 ist die Mitte der Slide. Ebene wie z-index, ' +
|
||
'leer heißt Standard.</div>' +
|
||
(std.prop
|
||
? '<div class="row" style="margin-top:8px">' +
|
||
'<button class="btn secondary" id="sz-minus">A−</button>' +
|
||
'<button class="btn secondary" id="sz-plus">A+</button></div>'
|
||
: "") +
|
||
'<div class="abschnitt">Ausrichten an der Slide</div>' +
|
||
'<div class="opts">' +
|
||
'<button data-waag="links">Links</button>' +
|
||
'<button data-waag="mitte">Mitte</button>' +
|
||
'<button data-waag="rechts">Rechts</button></div>' +
|
||
'<div class="opts" style="margin-top:4px">' +
|
||
'<button data-senk="oben">Oben</button>' +
|
||
'<button data-senk="mitte">Mitte</button>' +
|
||
'<button data-senk="unten">Unten</button></div>' +
|
||
'<div class="abschnitt">Auf allen Slides</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn secondary" id="align-pos" title="Dieses Element auf allen Slides an dieselbe Stelle">Position angleichen</button>' +
|
||
'<button class="btn secondary" id="align-size" title="Dieses Element auf allen Slides gleich groß">Größe angleichen</button>' +
|
||
'</div>' +
|
||
'<div class="abschnitt">Element</div>' +
|
||
'<div class="row">' +
|
||
'<button class="btn secondary" id="reset-one">Zurücksetzen</button>' +
|
||
'<button class="btn secondary" id="f-weg">Löschen</button>' +
|
||
'</div>' +
|
||
verstecktHtml(picked.slide);
|
||
if (wechsel) fitStrip();
|
||
}
|
||
const put = (sel, v) => {
|
||
const el = koerper.querySelector(sel);
|
||
if (el && document.activeElement !== el) el.value = v;
|
||
};
|
||
if (feld) put("#f-text", htmlZuText(SLIDES[picked.slide][feld]));
|
||
if (std.prop) put("#f-size", e.size || base);
|
||
put("#f-z", e.z == null ? "" : e.z);
|
||
const c = centreOf(picked.slide, picked.key);
|
||
if (c) { put("#f-dx", c.x); put("#f-dy", c.y); }
|
||
}
|
||
|
||
/* Auswahl aufheben, und damit das rechte Feld zu. Zwei Wege, weil einer
|
||
zu wenig war: der Knopf setzt voraus, dass man ihn sieht, und genau das
|
||
war nicht immer der Fall. Escape geht immer.
|
||
|
||
Nicht wenn gerade in einem Feld getippt wird -- dort soll Escape die
|
||
Eingabe verlassen und nicht die Auswahl wegnehmen. */
|
||
function auswahlAufheben() {
|
||
if (!picked) return;
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
}
|
||
document.getElementById("el-zu").addEventListener("click", auswahlAufheben);
|
||
document.addEventListener("keydown", e => {
|
||
if (e.key !== "Escape") return;
|
||
const z = document.activeElement;
|
||
if (z && /^(INPUT|TEXTAREA|SELECT)$/.test(z.tagName)) return;
|
||
auswahlAufheben();
|
||
});
|
||
|
||
document.getElementById("el-inhalt").addEventListener("click", ev => {
|
||
const zurueck = ev.target.closest("[data-zurueck]");
|
||
if (zurueck) {
|
||
const k = zurueck.dataset.zurueck;
|
||
const i = Number(zurueck.dataset.slide);
|
||
const set = (edits[i] || {})[k];
|
||
if (set) delete set.weg;
|
||
applyEditsAll();
|
||
document.getElementById("el-inhalt").dataset.for = "";
|
||
pick(i, k);
|
||
saveState();
|
||
setStatus(((STANDARD[k] || {}).label || k) + " ist wieder da", "ok");
|
||
return;
|
||
}
|
||
const b = ev.target.closest("button");
|
||
if (!b || !picked) return;
|
||
if (b.id === "sz-plus") { resize(1); return; }
|
||
if (b.id === "sz-minus") { resize(-1); return; }
|
||
if (b.id === "reset-one") { resetOne(); return; }
|
||
if (b.id === "align-pos") { alignAll("pos"); return; }
|
||
if (b.id === "align-size") { alignAll("size"); return; }
|
||
if (b.dataset.waag) { ausrichten(picked.slide, picked.key, b.dataset.waag, null); return; }
|
||
if (b.dataset.senk) { ausrichten(picked.slide, picked.key, null, b.dataset.senk); return; }
|
||
if (b.id !== "f-weg") return;
|
||
const i = picked.slide, key = picked.key;
|
||
if (key === "mock-2") {
|
||
// Beim zweiten Bild wirklich entfernen, nicht nur ausblenden: sonst
|
||
// stuende es weiter im Stand und im Auswahlfeld.
|
||
werteVon(i).media2 = null;
|
||
slidesNeuAufbauen();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
show(i);
|
||
syncInhalt();
|
||
} else {
|
||
(edits[i][key] = edits[i][key] || {}).weg = true;
|
||
applyEditsAll();
|
||
// Ausgewaehlt lassen: dann steht "Element zurueck" gleich bereit. Der
|
||
// Aufbau muss aber erzwungen werden, sonst fehlt die Rueckhol-Liste.
|
||
document.getElementById("el-inhalt").dataset.for = "";
|
||
syncEditPanel();
|
||
}
|
||
saveState();
|
||
setStatus("Element entfernt", "ok");
|
||
});
|
||
|
||
/* Eingabe wirkt sofort. Kein pick() danach: das baut das Feld neu auf und
|
||
der Fokus waere weg. */
|
||
document.getElementById("el-inhalt").addEventListener("input", ev => {
|
||
const el = ev.target;
|
||
// textarea zaehlt nicht als input: ohne das faellt das Textfeld durch.
|
||
if (!picked || !el.matches("input, textarea")) return;
|
||
/* Der Text gehoert der Slide, nicht der Bearbeitung: er wandert in
|
||
dieselbe Ueberschreibung, die auch das Inhalt-Feld links beschreibt. */
|
||
if (el.id === "f-text") {
|
||
const feld = TEXT_ZU_FELD[picked.key];
|
||
if (!feld) return;
|
||
werteVon(picked.slide)[feld] = textZuHtml(el.value);
|
||
slidesNeuAufbauen();
|
||
rebuildPage(picked.slide);
|
||
applyEditsAll();
|
||
pick(picked.slide, picked.key);
|
||
syncInhalt();
|
||
saveState();
|
||
return;
|
||
}
|
||
const raw = el.value.trim();
|
||
const n = Number(raw);
|
||
if (raw !== "" && !Number.isFinite(n)) return;
|
||
const set = (edits[picked.slide][picked.key] = edits[picked.slide][picked.key] || {});
|
||
if (el.id === "f-dx" || el.id === "f-dy") {
|
||
// Der Wert ist eine Stelle auf der Slide, kein Verschiebebetrag. Der
|
||
// Unterschied zur jetzigen Stelle wandert deshalb in den Versatz.
|
||
const c = centreOf(picked.slide, picked.key);
|
||
if (!c) return;
|
||
const ziel = raw === "" ? 0 : Math.round(n);
|
||
if (el.id === "f-dx") set.dx = (set.dx || 0) + (ziel - c.x);
|
||
else set.dy = (set.dy || 0) + (ziel - c.y);
|
||
}
|
||
else if (el.id === "f-z") {
|
||
if (raw === "") delete set.z;
|
||
else set.z = Math.round(n);
|
||
}
|
||
// Leere Groesse heisst Standard, nicht null.
|
||
else if (el.id === "f-size") {
|
||
if (raw === "") delete set.size;
|
||
else set.size = Math.max(8, Math.round(n));
|
||
}
|
||
applyEditsAll();
|
||
saveState();
|
||
});
|
||
/* Welche Kategorien offen sind, ist Bedienvorliebe und gehört nicht in den
|
||
Projektstand: gilt für beide Projekte gleichermaßen. */
|
||
(function rememberGroups() {
|
||
const KEY = "hivegrid.ui.gruppen";
|
||
let open = [];
|
||
try { open = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (_) {}
|
||
document.querySelectorAll("details.group[data-grp]").forEach(d => {
|
||
if (open.includes(d.dataset.grp)) d.open = true;
|
||
d.addEventListener("toggle", () => {
|
||
const now = [...document.querySelectorAll("details.group[open][data-grp]")]
|
||
.map(x => x.dataset.grp);
|
||
try { localStorage.setItem(KEY, JSON.stringify(now)); } catch (_) {}
|
||
});
|
||
});
|
||
})();
|
||
|
||
document.getElementById("forget").addEventListener("click", forgetProject);
|
||
document.getElementById("vorschau").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
setVorschau(b.dataset.p === "an");
|
||
});
|
||
|
||
/* In der Vorschau ist alles festgelegt: keine Auswahl, kein Ziehen, keine
|
||
Rahmen. Blaettern und Zoomen bleiben, das ist der Sinn der Sache. */
|
||
function setVorschau(an) {
|
||
editMode = an ? "aus" : "an";
|
||
press("vorschau", "p", an ? "an" : "aus");
|
||
deck.querySelectorAll(".slide").forEach(el => { el.dataset.editmode = editMode; });
|
||
if (an) {
|
||
picked = null;
|
||
deck.querySelectorAll("[data-edit]").forEach(n => n.classList.remove("picked"));
|
||
syncEditPanel();
|
||
}
|
||
}
|
||
|
||
/* ─── Angleichen über alle Slides ────────────────────────────────────────
|
||
Das gewählte Element ist die Vorlage, alle gleichnamigen auf den anderen
|
||
Slides ziehen nach. Angeglichen wird die Stelle, nicht der Versatz: jedes
|
||
Element liegt von sich aus woanders, und nur die Stelle ist vergleichbar.
|
||
In der Einzelansicht haben die übrigen Holder kein Layout, weil sie
|
||
ausgeblendet sind. Jeder wird deshalb kurz sichtbar gemacht — derselbe
|
||
Weg, den auch der Export geht. */
|
||
function alignAll(was) {
|
||
if (!picked) return;
|
||
const key = picked.key;
|
||
const ziel = was === "size" ? null : centreOf(picked.slide, key);
|
||
if (was !== "size" && !ziel) { setStatus("Element nicht messbar", "err"); return; }
|
||
const size = (edits[picked.slide][key] || {}).size;
|
||
const holders = deck.querySelectorAll(".holder");
|
||
let n = 0;
|
||
SLIDES.forEach((_, i) => {
|
||
if (i === picked.slide) return;
|
||
const holder = holders[i];
|
||
if (!holder || !holder.querySelector('.page[data-j="' + i + '"] [data-edit="' + key + '"]')) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const set = (edits[i][key] = edits[i][key] || {});
|
||
if (was === "size") {
|
||
if (size == null) delete set.size; else set.size = size;
|
||
} else {
|
||
const c = centreOf(i, key);
|
||
if (c) {
|
||
set.dx = (set.dx || 0) + (ziel.x - c.x);
|
||
set.dy = (set.dy || 0) + (ziel.y - c.y);
|
||
}
|
||
}
|
||
holder.style.display = restore;
|
||
n++;
|
||
});
|
||
applyEditsAll();
|
||
syncEditPanel();
|
||
saveState();
|
||
const label = (STANDARD[key] || {}).label || key;
|
||
setStatus(label + ": " + (was === "size" ? "Größe" : "Position") +
|
||
" auf " + n + " weitere Slide" + (n === 1 ? "" : "s") + " übertragen", "ok");
|
||
}
|
||
|
||
|
||
/* ─── Merkzettel ─────────────────────────────────────────────────────────
|
||
Der gemerkte Stand liegt als eigenes Projekt in state/, also im Repo und
|
||
damit auch auf der anderen Maschine. Zurueckgeholt wird er, indem er zum
|
||
laufenden Stand gemacht und die Seite neu geladen wird: dann greift
|
||
derselbe Ladepfad wie sonst auch, statt dass ich hier von Hand jede
|
||
Einstellung nachziehe und dabei eine vergesse. */
|
||
const SNAP_PROJECT = PROJECT + "-merk";
|
||
const snapSave = document.getElementById("snap-save");
|
||
const snapLoad = document.getElementById("snap-load");
|
||
|
||
// Was aus der Merkdatei kommt, wird geprueft, bevor es den laufenden Stand
|
||
// ersetzt: Slide-Anzahl und Version muessen zu diesem Blatt passen.
|
||
function snapUsable(d) {
|
||
return !!d && typeof d.v === "number" && d.v >= 1 && d.v <= STATE_V
|
||
&& Array.isArray(d.edits) && d.edits.length === SLIDES.length
|
||
&& Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length
|
||
&& Array.isArray(d.bgState) && d.bgState.length === SLIDES.length;
|
||
}
|
||
|
||
// Immer frisch: der Merkzettel wird waehrend der Arbeit ueberschrieben, ein
|
||
// gecachter Treffer wuerde den Stand von vorhin zurueckholen.
|
||
function snapUrl() {
|
||
return "./state/" + SNAP_PROJECT + ".json?t=" + Date.now();
|
||
}
|
||
|
||
async function readSnap() {
|
||
const res = await fetch(snapUrl(), { cache: "no-store" });
|
||
if (!res.ok) throw new Error("kein gemerkter Stand");
|
||
return await res.json();
|
||
}
|
||
|
||
async function snapExists() {
|
||
try { return snapUsable(await readSnap()); }
|
||
catch (_) { return false; }
|
||
}
|
||
snapExists().then(ok => { snapLoad.disabled = !ok; });
|
||
|
||
snapSave.addEventListener("click", async () => {
|
||
snapSave.disabled = true;
|
||
try {
|
||
const mine = currentState();
|
||
const res = await fetch("/state?project=" + SNAP_PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(mine),
|
||
});
|
||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||
// Gegengelesen: gemerkt ist erst, was auch wirklich in der Datei steht.
|
||
// Ein "ok" vom Server allein hat hier schon einmal zu wenig bewiesen.
|
||
const back = await readSnap();
|
||
if (!snapUsable(back)) throw new Error("Datei unbrauchbar zurueckgelesen");
|
||
if (JSON.stringify(back.edits) !== JSON.stringify(mine.edits)
|
||
|| JSON.stringify(back.bgState) !== JSON.stringify(mine.bgState)
|
||
|| JSON.stringify(back.layoutState) !== JSON.stringify(mine.layoutState)) {
|
||
throw new Error("Datei weicht vom Stand ab");
|
||
}
|
||
snapLoad.disabled = false;
|
||
setStatus("Stand gemerkt und gegengelesen", "ok");
|
||
} catch (err) {
|
||
snapLoad.disabled = !(await snapExists());
|
||
setStatus("Merken fehlgeschlagen: " + err.message, "err");
|
||
} finally { snapSave.disabled = false; }
|
||
});
|
||
|
||
snapLoad.addEventListener("click", async () => {
|
||
snapLoad.disabled = true;
|
||
try {
|
||
const d = await readSnap();
|
||
// Erst pruefen, dann ersetzen. Ein Merkzettel, der nicht passt, darf
|
||
// nicht den Stand kosten, der gerade auf dem Schirm steht.
|
||
if (!snapUsable(d)) throw new Error("gemerkter Stand passt nicht zu diesem Blatt");
|
||
// Rueckfahrkarte: der Stand von jetzt wird als <projekt>-vorher abgelegt,
|
||
// bevor er ueberschrieben wird. Ein Fehlklick ist damit umkehrbar.
|
||
await fetch("/state?project=" + PROJECT + "-vorher", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(currentState()),
|
||
}).catch(() => {});
|
||
// Den laufenden Timer entschaerfen und dichtmachen, sonst schreibt der
|
||
// Reload den alten Stand ueber den gerade zurueckgeholten.
|
||
clearTimeout(pushTimer); pushTimer = null;
|
||
stateLocked = true;
|
||
const put = await fetch("/state?project=" + PROJECT, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(d),
|
||
});
|
||
if (!put.ok) throw new Error("HTTP " + put.status);
|
||
// Gegenlesen, bevor neu geladen wird: erst wenn die Datei steht, ist der
|
||
// Reload sicher. Sonst startet das Blatt ohne Stand.
|
||
const back = await fetch(STATE_URL + "?t=" + Date.now(), { cache: "no-store" });
|
||
if (!back.ok || !snapUsable(await back.json())) throw new Error("Stand nicht geschrieben");
|
||
try { localStorage.setItem(STORE, JSON.stringify(d)); } catch (_) {}
|
||
location.reload();
|
||
} catch (err) {
|
||
stateLocked = false;
|
||
snapLoad.disabled = false;
|
||
setStatus("Zurueckholen fehlgeschlagen: " + err.message, "err");
|
||
}
|
||
});
|
||
|
||
/* ─── Menueleiste ────────────────────────────────────────────────────────
|
||
Die Eintraege fuehren nichts selbst aus, sondern betaetigen die Schalter
|
||
im Bedienfeld. Damit gibt es weiter genau eine Stelle, an der ein
|
||
Verhalten steht, und die Leiste kann nicht auseinanderlaufen.
|
||
data-klick Knopf mit dieser id anklicken
|
||
data-opt Knopf einer Optionsgruppe: gruppe:attribut:wert
|
||
data-tun benannte Aktion
|
||
data-gehe anderes Projekt oeffnen */
|
||
(function menueleiste() {
|
||
const bar = document.getElementById("menubar");
|
||
if (!bar) return;
|
||
const menues = [...bar.querySelectorAll(".menu")];
|
||
const zu = () => menues.forEach(m => delete m.dataset.offen);
|
||
|
||
const AKTION = { undo, redo, vorschau: () => setVorschau(editMode === "an") };
|
||
|
||
// Haken und graue Eintraege erst beim Oeffnen setzen: vorher weiss die
|
||
// Leiste nicht, was gerade gilt.
|
||
function stand(m) {
|
||
m.querySelectorAll("button[data-opt]").forEach(b => {
|
||
const [gruppe, attr, wert] = b.dataset.opt.split(":");
|
||
const q = document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]');
|
||
const an = q && q.getAttribute("aria-pressed") === "true";
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (an ? "✓" : "") + "</span>");
|
||
});
|
||
m.querySelectorAll('button[data-tun="vorschau"]').forEach(b => {
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (editMode === "aus" ? "✓" : "") + "</span>");
|
||
});
|
||
m.querySelectorAll("button[data-klick]").forEach(b => {
|
||
const q = document.getElementById(b.dataset.klick);
|
||
b.disabled = !q || q.disabled;
|
||
});
|
||
m.querySelectorAll("button[data-gehe]").forEach(b => {
|
||
const hier = b.dataset.gehe.endsWith(location.pathname.split("/").pop() || "post1.html");
|
||
b.querySelector(".haken")?.remove();
|
||
b.insertAdjacentHTML("afterbegin",
|
||
'<span class="haken">' + (hier ? "✓" : "") + "</span>");
|
||
});
|
||
}
|
||
|
||
bar.addEventListener("click", e => {
|
||
const titel = e.target.closest(".menu > button");
|
||
if (titel) {
|
||
const m = titel.parentElement;
|
||
const war = m.dataset.offen === "ja";
|
||
zu();
|
||
if (!war) { m.dataset.offen = "ja"; stand(m); }
|
||
return;
|
||
}
|
||
const el = e.target.closest(".liste button");
|
||
if (!el || el.disabled) return;
|
||
zu();
|
||
if (el.dataset.gehe) { location.href = el.dataset.gehe; return; }
|
||
if (el.dataset.tun) { (AKTION[el.dataset.tun] || (() => {}))(); return; }
|
||
if (el.dataset.klick) { document.getElementById(el.dataset.klick)?.click(); return; }
|
||
if (el.dataset.opt) {
|
||
const [gruppe, attr, wert] = el.dataset.opt.split(":");
|
||
document.querySelector("#" + gruppe + " button[data-" + attr + '="' + wert + '"]')?.click();
|
||
}
|
||
});
|
||
|
||
// Ueberfahren wechselt das offene Menue, wie gewohnt.
|
||
bar.addEventListener("mouseover", e => {
|
||
const titel = e.target.closest(".menu > button");
|
||
if (!titel || !menues.some(m => m.dataset.offen === "ja")) return;
|
||
const m = titel.parentElement;
|
||
if (m.dataset.offen === "ja") return;
|
||
zu(); m.dataset.offen = "ja"; stand(m);
|
||
});
|
||
|
||
document.addEventListener("mousedown", e => { if (!bar.contains(e.target)) zu(); });
|
||
document.addEventListener("keydown", e => { if (e.key === "Escape") zu(); });
|
||
})();
|
||
|
||
document.getElementById("reset-all").addEventListener("click", resetAll);
|
||
|
||
// Übersicht der Standards, direkt aus derselben Tabelle wie der Reset.
|
||
(function renderStandards() {
|
||
const groups = [
|
||
["Schrift", ["badge", "headline", "body", "wabe-tagline", "wabe-claim", "title-head", "title-sub"]],
|
||
["Breite", ["wabe-logo", "logo-full", "screen-logo", "laptop", "szene"]],
|
||
];
|
||
let html = "";
|
||
for (const [title, keys] of groups) {
|
||
html += "<h4>" + title + "</h4><table>";
|
||
for (const k of keys) {
|
||
const st = STANDARD[k];
|
||
html += "<tr><td>" + st.label + "</td><td>" + st.size + " px</td></tr>";
|
||
}
|
||
html += "</table>";
|
||
}
|
||
html += '<h4>Fest</h4><table>' +
|
||
'<tr><td>Slide</td><td>1080 × 1350</td></tr>' +
|
||
'<tr><td>Innenabstand</td><td>92 px</td></tr>' +
|
||
'<tr><td>Tag ausgerückt</td><td>−20 px</td></tr>' +
|
||
'<tr><td>Mockup</td><td>1090 px · 16:10</td></tr>' +
|
||
'</table>';
|
||
document.getElementById("std-list").innerHTML = html;
|
||
})();
|
||
|
||
document.getElementById("views").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { view = b.dataset.w; deck.dataset.view = view; press("views", "w", view); zoom = null; show(current); saveState(); }
|
||
});
|
||
window.addEventListener("resize", fitStrip);
|
||
|
||
/* deltaMode: 0 = Pixel, 1 = Zeilen (Firefox), 2 = Seiten. Ohne Umrechnung
|
||
zoomt Firefox pro Rastung um ein Vielfaches weniger als Chrome. */
|
||
function wheelPx(v, mode) { return v * (mode === 1 ? 16 : mode === 2 ? 400 : 1); }
|
||
|
||
/* Blankes Rad zoomt — eine gewöhnliche PC-Maus soll ohne Zusatztaste
|
||
auskommen. Strg/Cmd + Rad tut dasselbe, damit der Trackpad-Pinch (der
|
||
ctrlKey mitschickt) weiter greift. Waagerechtes Wischen und ⇧ + Rad
|
||
schieben dagegen den Ausschnitt: ein Trackpad darf sich nicht wie eine
|
||
Zoomwippe anfühlen. */
|
||
document.getElementById("scroller").addEventListener("wheel", e => {
|
||
const sc = e.currentTarget;
|
||
const dx = wheelPx(e.deltaX, e.deltaMode), dy = wheelPx(e.deltaY, e.deltaMode);
|
||
if (e.shiftKey || (!dy && dx)) {
|
||
e.preventDefault();
|
||
sc.scrollLeft += (dx || dy);
|
||
return;
|
||
}
|
||
if (!dy) return;
|
||
e.preventDefault();
|
||
const r = sc.getBoundingClientRect();
|
||
// Exponentiell gerechnet fühlen sich Rastung (~100 px auf einen Schlag)
|
||
// und Trackpad (viele kleine Werte) gleich an. Der Deckel hält die
|
||
// Schwung-Schübe im Zaum, die macOS nach dem Loslassen nachschiebt.
|
||
const k = Math.min(3, Math.max(1 / 3, Math.exp(-dy * 0.0015)));
|
||
setZoom((zoom || autoFit()) * k,
|
||
{ x: e.clientX - r.left, y: e.clientY - r.top });
|
||
}, { passive: false });
|
||
|
||
/* Panning: linke Maustaste auf der Fläche reicht. Leertaste und mittlere
|
||
Taste bleiben als gewohnte Wege bestehen — im Bearbeiten-Modus sind sie
|
||
der einzige, denn dort gehört der linke Zug dem Element unter dem Zeiger. */
|
||
(function panning() {
|
||
const sc = document.getElementById("scroller");
|
||
let space = false, active = null, dragged = false;
|
||
|
||
document.addEventListener("keydown", e => {
|
||
if (e.code !== "Space" || e.target.matches("input, textarea")) return;
|
||
e.preventDefault();
|
||
if (!space) { space = true; sc.classList.add("panready"); }
|
||
});
|
||
document.addEventListener("keyup", e => {
|
||
if (e.code !== "Space") return;
|
||
space = false; sc.classList.remove("panready");
|
||
});
|
||
|
||
sc.addEventListener("mousedown", e => {
|
||
dragged = false;
|
||
if (!space && e.button !== 1) {
|
||
// Linke Taste zieht die Fläche, außer sie hat im Bearbeiten-Modus
|
||
// ein Element erwischt: das verschiebt sich selbst.
|
||
if (e.button !== 0) return;
|
||
if (editMode === "an" && e.target.closest("[data-edit]")) return;
|
||
}
|
||
e.preventDefault();
|
||
active = { x: e.clientX, y: e.clientY, l: sc.scrollLeft, t: sc.scrollTop };
|
||
sc.classList.add("panning");
|
||
});
|
||
document.addEventListener("mousemove", e => {
|
||
if (!active) return;
|
||
const dx = e.clientX - active.x, dy = e.clientY - active.y;
|
||
// Erst ab ein paar Pixeln ist es ein Zug und kein Klick, sonst
|
||
// verschluckt jede zittrige Hand die Slide-Auswahl.
|
||
if (Math.abs(dx) + Math.abs(dy) > 3) dragged = true;
|
||
sc.scrollLeft = active.l - dx;
|
||
sc.scrollTop = active.t - dy;
|
||
});
|
||
document.addEventListener("mouseup", () => {
|
||
active = null; sc.classList.remove("panning");
|
||
});
|
||
// Nach echtem Ziehen den Klick schlucken: sonst springt die Auswahl auf
|
||
// die Slide, über der die Maus zufällig loslässt.
|
||
sc.addEventListener("click", e => {
|
||
if (!dragged) return;
|
||
dragged = false;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
}, true);
|
||
})();
|
||
|
||
document.getElementById("zoom-in").addEventListener("click", () => setZoom((zoom || autoFit()) * 1.25));
|
||
document.getElementById("zoom-out").addEventListener("click", () => setZoom((zoom || autoFit()) / 1.25));
|
||
document.getElementById("zoom-fit").addEventListener("click", () => { zoom = null; fitStrip(); });
|
||
|
||
nav.addEventListener("click", e => {
|
||
const b = e.target.closest("button"); if (b) show(Number(b.dataset.n));
|
||
});
|
||
document.getElementById("variants").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { variant = b.dataset.v; press("variants", "v", variant); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("layouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { layoutState[current] = b.dataset.l; syncLayoutButtons(); rebuildPage(current); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("ints").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { bgState[current].int = b.dataset.i; syncBgButtons(); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("tagouts").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { tagOut = b.dataset.o; press("tagouts", "o", tagOut); applyAll(); saveState(); }
|
||
});
|
||
document.getElementById("frames").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (b) { frameStyle = b.dataset.f; press("frames", "f", frameStyle); applyAll(); saveState(); }
|
||
});
|
||
/* 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");
|
||
/* Ganz zum Schluss: der Rendermodus raeumt die Seite leer, und alles,
|
||
was danach noch ein Element sucht, faende keines mehr. Ein Aufruf
|
||
weiter oben hat genau das ausgeloest -- "Cannot set properties of
|
||
null" beim Freischalten der Export-Knoepfe. */
|
||
animNurModus();
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus("Fehler beim Laden: " + err.message, "err");
|
||
}
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|