Geprueft im laufenden Team-Chat: an jeder Nachricht haengt ein Menue mit Reagieren, Antworten, Im Thread antworten, Anpinnen, Aufgabe erstellen, Bearbeiten, Loeschen. Das ist die Funktion, die die Website meint mit "Aus 'mach das mal' wird mit einem Klick eine echte Aufgabe". Die Animation zeigt jetzt genau das: Tobias' Nachricht anfassen, der Pfeil erscheint an ihr, Menue auf, "Aufgabe erstellen", die Aufgabenkarte klappt in der Nachricht auf, die Systemzeile bestaetigt sie im Projekt. Tobias' Nachricht und keine andere: die Aufgabenkarte aus der Quelle traegt "Tobias Wagner · offen" als Zustaendigkeit. An einer fremden Nachricht muesste der Name erfunden werden. So bleiben alle vier Felder der Karte genau die aus GeneralTeamChatPage.tsx. Der Vorgaenger nahm das Menue hinter dem Pluszeichen. Das war sauber belegt -- Datei, Zeile, Grep ueber beide Repos, dazu die Staging-API, die eine Aufgabe nur mit contact/company/lead/project verknuepft und eine Nachricht als Bezug gar nicht kennt -- und trotzdem falsch. Ein Pitch-Paket vom Juli belegt, was im Juli gebaut war, nicht was das Produkt heute kann. Wo eine Aussage der Website keine Entsprechung im vorliegenden Code hat, ist das ein Grund nachzufragen und nicht, die Aussage auf den Code zu biegen. Steht so in POST-6.md §6. Sechs statt acht Sekunden: das Tippen faellt mit dem richtigen Weg weg. Instagram nimmt je Carousel-Element drei bis sechzig Sekunden an, die Laenge richtet sich also allein nach dem Ablauf. Dazu in den Bausteinen: fuenf Symbole fuer das Menue, und eine Blase kann eine zweite Anhangkarte tragen, die ueber einen Anteil aufklappt -- aufgedeckt per clipPath, nicht gestaucht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6531 lines
313 KiB
HTML
6531 lines
313 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 6 · Team-Chat</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: 13px 20px 16px;
|
||
max-height: 42vh; overflow-y: auto;
|
||
}
|
||
.animkopf { display: flex; align-items: baseline; gap: 10px; margin-bottom: 11px; }
|
||
.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; }
|
||
.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>
|
||
<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 6 · Team-Chat</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. */
|
||
const VORLAGEN = [
|
||
{
|
||
/* Das Titelbild ist der Chat selbst. Die Ueberschrift steht woertlich
|
||
als headline in lib/produkt-daten.ts der Website, Eintrag "chat". */
|
||
key: "chat", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Team-Chat",
|
||
head: 'Büro und Baustelle,<br><span class="a">endlich im selben Chat.</span>',
|
||
body: "",
|
||
vis: "chat", visW: 948, visLeft: 66, visBottom: 120,
|
||
},
|
||
{
|
||
/* Der Fliesstext ist das Merkmal "Themen-Kanäle" von der Website,
|
||
leicht gekuerzt: dort steht "Schluss mit dem einen Verlauf, in dem
|
||
alles durcheinandergeht. Ein Kanal pro Projekt." */
|
||
key: "kanaele", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Die Kanäle",
|
||
head: 'Ein Kanal<br><span class="a">pro Projekt.</span>',
|
||
body: "Schluss mit dem einen Verlauf, in dem alles durcheinandergeht. Jeder Kanal hängt an seiner Projektnummer.",
|
||
/* Negativer visLeft: die Zeichnung beginnt links vor der Seite. Im
|
||
durchgehenden Blatt hat .page kein overflow, also landet dieser Teil
|
||
auf der Nachbarslide -- die Schlangenlinie faengt damit schon auf
|
||
Slide 1 an und laeuft hier weiter. Das ist derselbe Ueberstand, den
|
||
rules.md 5 sonst als Falle beschreibt, hier aber gewollt. */
|
||
vis: "kanaele", visW: 896, visLeft: 92, visBottom: 120,
|
||
},
|
||
{
|
||
/* Der Satz aus dem Redaktionsplan: "Foto rein, Aufgabe raus." Die
|
||
Systemzeile darunter ist es, die den Unterschied macht -- die Aufgabe
|
||
liegt danach im Projekt und nicht nur im Verlauf. */
|
||
key: "aufgabe", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Die Aufgabe",
|
||
head: 'Foto rein,<br><span class="a">Aufgabe raus.</span>',
|
||
body: "Aus „mach das mal“ wird mit einem Klick eine echte Aufgabe. Mit Frist, mit Namen, im Projekt.",
|
||
vis: "aufgabe", visW: 860, visLeft: 110, visBottom: 120,
|
||
},
|
||
{
|
||
key: "menue", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Das Pluszeichen",
|
||
head: 'Nicht nur<br><span class="a">Text.</span>',
|
||
body: "Dokument, Fotos, Sprachnachricht, Kontakt. Soll daraus etwas werden: Termin oder Aufgabe.",
|
||
vis: "menue", visW: 940, visLeft: 70, visBottom: 120,
|
||
},
|
||
{
|
||
/* Ein Ausblick, kein Beleg. Die App gibt es noch nicht -- deshalb steht
|
||
die Marke "In Arbeit" ueber dem Geraet, und der Fliesstext sagt es
|
||
geradeheraus. Auf dem Schirm ist nichts Neues: dieselben zwei
|
||
Nachrichten wie auf Slide 1, nur schmaler gesetzt.
|
||
|
||
"Im Buero am Rechner, auf der Baustelle am Handy" steht als Merkmal
|
||
"Desktop & Mobile" auf der Website. Der Satz beschreibt das Ziel,
|
||
nicht den heutigen Stand -- deshalb ist er hier nicht zitiert. */
|
||
key: "mobil", type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Bald verfügbar",
|
||
head: 'Der Chat für unterwegs<br><span class="a">wird gerade gebaut.</span>',
|
||
body: "Die mobile App legen wir gerade auf die Baustelle. Am Desktop bist du schon voll dabei, die Version fürs Handy folgt in Kürze.",
|
||
vis: "mobil", visW: 850, visLeft: 115, visBottom: 120,
|
||
},
|
||
];
|
||
|
||
/* ═══ Animationen ════════════════════════════════════════════════════════
|
||
Sie stehen mit Absicht nicht in SLIDES. Eine Animation ist kein Standbild
|
||
im Streifen: sie wird einzeln gebaut, einzeln exportiert und geht als
|
||
eigenes Element ins Carousel -- oder eben nicht mit, wenn nur die Bilder
|
||
gepostet werden. Der Bildexport laesst sie deshalb unberuehrt.
|
||
|
||
Aufbau sonst gleich wie eine Slide: dieselben Felder, dieselbe
|
||
Zeichenfunktion aus VIS, dasselbe Blatt in 1080 x 1350. Der Unterschied
|
||
ist `dauer` und der zweite Wert t, den die Zeichenfunktion bekommt. */
|
||
const ANIMS = [
|
||
{
|
||
/* Sechs Sekunden. Instagram nimmt im Carousel drei bis sechzig an; das
|
||
ist also weit von jeder Grenze weg, und die Laenge richtet sich
|
||
allein nach dem Ablauf. Vier Schritte -- anfassen, waehlen,
|
||
aufklappen, bestaetigen -- brauchen etwa so lange, und eine kurze
|
||
Schleife wiederholt sich oefter, waehrend jemand liest. */
|
||
key: "animChat", type: "feature", dauer: 6,
|
||
eyebrow: "Bewegtbild",
|
||
head: 'Getippt,<br><span class="a">und schon im Projekt.</span>',
|
||
body: "Eine Nachricht, ein Klick, und die Aufgabe hängt am richtigen Projekt.",
|
||
vis: "animChat", visW: 700, visLeft: 190, visBottom: 120,
|
||
},
|
||
];
|
||
|
||
|
||
/* Vorlage für eine selbst angelegte Slide. Sie startet bewusst mit Text
|
||
drin: eine leere Slide sieht aus wie ein Fehler, und man sieht nicht,
|
||
welches Feld welche Zeile füllt. */
|
||
const kopie = x => JSON.parse(JSON.stringify(x));
|
||
|
||
const NEUE_SLIDE = {
|
||
type: "feature",
|
||
layouts: [{ id: "standard", label: "Standard" }],
|
||
eyebrow: "Neuer Tag",
|
||
head: 'Überschrift mit <span class="a">Akzent</span>',
|
||
body: "Beschreibung dieser Slide. Zwei bis drei Zeilen sitzen am besten.",
|
||
img: "./assets/screens/pipelines.png",
|
||
mockW: 1090, mockAR: 1.6,
|
||
imgScale: 1, imgX: "0%", imgY: "0%",
|
||
};
|
||
|
||
/* Alle Screens im Ordner. Eine selbst angelegte Slide kann jeden davon
|
||
wählen, deshalb werden sie vollständig geladen und nicht nur die, die
|
||
eine Vorlage zufällig gerade benutzt. */
|
||
/* Ausgangsliste. Der Server liefert beim Start die tatsaechlich vorhandenen
|
||
Dateien nach, damit importierte Bilder sofort auftauchen und die Liste
|
||
nicht im Code gepflegt werden muss. */
|
||
let BILDER = [
|
||
{ pfad: "./assets/screens/pipelines.png", name: "Pipelines · Board" },
|
||
{ pfad: "./assets/screens/pipelines-liste.png", name: "Pipelines · Liste" },
|
||
{ pfad: "./assets/screens/pipelines-detail.png", name: "Pipelines · Detail" },
|
||
{ pfad: "./assets/screens/workflow.png", name: "Workflow · Ablauf" },
|
||
{ pfad: "./assets/screens/workflow-bausteine.png", name: "Workflow · Bausteine" },
|
||
{ pfad: "./assets/screens/chat.png", name: "Chat" },
|
||
{ pfad: "./assets/screens/chat-tablet.png", name: "Chat · Tablet" },
|
||
{ pfad: "./assets/screens/chat-menue.png", name: "Chat · Menü" },
|
||
{ pfad: "./assets/screens/form-builder.png", name: "Form Builder" },
|
||
];
|
||
|
||
/* ─── Visualisierungen ───────────────────────────────────────────────────
|
||
Jede Slide nennt ueber "vis" ihr Bild. Gebaut wird es hier aus HTML, nicht
|
||
aus einer Aufnahme: so bleibt es beim Export scharf und laesst sich wie
|
||
jedes andere Element im Canvas verschieben. */
|
||
/* ─── Skizzen-Primitive ─────────────────────────────────────────────────────
|
||
Handzeichnung aus gesetztem Zufall. Der Keim ist fest: der Export rendert
|
||
bei fehlgeschlagener Pruefung bis zu fuenfmal neu, und jeder Durchgang muss
|
||
denselben Strich ergeben. Mit ungesetztem Math.random zittert jedes Bild
|
||
anders und die Kontrolle vergleicht gegen etwas, das es nie wieder gibt. */
|
||
let _s = 0;
|
||
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. */
|
||
const FEATHER = {
|
||
"hard-hat": "<path d=\"M10 10V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5\"/> <path d=\"M14 6a6 6 0 0 1 6 6v3\"/> <path d=\"M4 15v-3a6 6 0 0 1 6-6\"/> <rect x=\"2\" y=\"15\" width=\"20\" height=\"4\" rx=\"1\"/>",
|
||
"building": "<rect width=\"16\" height=\"20\" x=\"4\" y=\"2\" rx=\"2\"/> <path d=\"M9 22v-4h6v4\"/> <path d=\"M8 6h.01\"/> <path d=\"M16 6h.01\"/> <path d=\"M12 6h.01\"/> <path d=\"M12 10h.01\"/> <path d=\"M12 14h.01\"/> <path d=\"M16 10h.01\"/> <path d=\"M16 14h.01\"/> <path d=\"M8 10h.01\"/> <path d=\"M8 14h.01\"/>",
|
||
"briefcase": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"7\" rx=\"2\" ry=\"2\"/> <path d=\"M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16\"/>",
|
||
"folder-open": "<path d=\"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2\"/>",
|
||
"mic": "<path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z\"/> <path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"/> <line x1=\"12\" x2=\"12\" y1=\"19\" y2=\"22\"/>",
|
||
"paperclip": "<path d=\"m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\"/>",
|
||
"phone": "<path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"/>",
|
||
"pin": "<line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"22\"/> <path d=\"M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z\"/>",
|
||
"plus": "<path d=\"M5 12h14\"/> <path d=\"M12 5v14\"/>",
|
||
"send": "<path d=\"m22 2-7 20-4-9-9-4Z\"/> <path d=\"M22 2 11 13\"/>",
|
||
"tags": "<path d=\"M9 5H2v7l6.29 6.29c.94.94 2.48.94 3.42 0l3.58-3.58c.94-.94.94-2.48 0-3.42L9 5Z\"/> <path d=\"M6 9.01V9\"/> <path d=\"m15 5 6.3 6.3a2.4 2.4 0 0 1 0 3.4L17 19\"/>",
|
||
"euro": "<path d=\"M4 10h12\"/> <path d=\"M4 14h9\"/> <path d=\"M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2\"/>",
|
||
"calendar": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\" ry=\"2\"/> <line x1=\"16\" x2=\"16\" y1=\"2\" y2=\"6\"/> <line x1=\"8\" x2=\"8\" y1=\"2\" y2=\"6\"/> <line x1=\"3\" x2=\"21\" y1=\"10\" y2=\"10\"/>",
|
||
"hash": "<line x1=\"4\" x2=\"20\" y1=\"9\" y2=\"9\"/> <line x1=\"4\" x2=\"20\" y1=\"15\" y2=\"15\"/> <line x1=\"10\" x2=\"8\" y1=\"3\" y2=\"21\"/> <line x1=\"16\" x2=\"14\" y1=\"3\" y2=\"21\"/>",
|
||
"circle-dot": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <circle cx=\"12\" cy=\"12\" r=\"1\"/>",
|
||
"coins": "<circle cx=\"8\" cy=\"8\" r=\"6\"/> <path d=\"M18.09 10.37A6 6 0 1 1 10.34 18\"/> <path d=\"M7 6h1v4\"/> <path d=\"m16.71 13.88.7.71-2.82 2.82\"/>",
|
||
"kanban-square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"M8 7v7\"/> <path d=\"M12 7v4\"/> <path d=\"M16 7v9\"/>",
|
||
"lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
|
||
"play-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <polygon points=\"10 8 16 12 10 16 10 8\"/>",
|
||
"flag": "<path d=\"M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z\"/> <line x1=\"4\" x2=\"4\" y1=\"22\" y2=\"15\"/>",
|
||
"clock": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <polyline points=\"12 6 12 12 16 14\"/>",
|
||
"git-pull-request": "<circle cx=\"18\" cy=\"18\" r=\"3\"/> <circle cx=\"6\" cy=\"6\" r=\"3\"/> <path d=\"M13 6h3a2 2 0 0 1 2 2v7\"/> <line x1=\"6\" x2=\"6\" y1=\"9\" y2=\"21\"/>",
|
||
"git-merge": "<circle cx=\"18\" cy=\"18\" r=\"3\"/> <circle cx=\"6\" cy=\"6\" r=\"3\"/> <path d=\"M6 21V9a9 9 0 0 0 9 9\"/>",
|
||
"signpost": "<path d=\"M12 3v3\"/> <path d=\"M18.5 13h-13L2 9.5 5.5 6h13L22 9.5Z\"/> <path d=\"M12 13v8\"/>",
|
||
"variable": "<path d=\"M8 21s-4-3-4-9 4-9 4-9\"/> <path d=\"M16 3s4 3 4 9-4 9-4 9\"/> <line x1=\"15\" x2=\"9\" y1=\"9\" y2=\"15\"/> <line x1=\"9\" x2=\"15\" y1=\"9\" y2=\"15\"/>",
|
||
"square-check": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/> <path d=\"m9 12 2 2 4-4\"/>",
|
||
"user-pen": "<path d=\"M11.5 15H7a4 4 0 0 0-4 4v2\"/> <path d=\"M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z\"/> <circle cx=\"10\" cy=\"7\" r=\"4\"/>",
|
||
"inbox": "<polyline points=\"22 12 16 12 14 15 10 15 8 12 2 12\"/> <path d=\"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\"/>",
|
||
"bell-ring": "<path d=\"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9\"/> <path d=\"M10.3 21a1.94 1.94 0 0 0 3.4 0\"/> <path d=\"M4 2C2.8 3.7 2 5.7 2 8\"/> <path d=\"M22 8c0-2.3-.8-4.3-2-6\"/>",
|
||
"git-branch": "<line x1=\"6\" x2=\"6\" y1=\"3\" y2=\"15\"/> <circle cx=\"18\" cy=\"6\" r=\"3\"/> <circle cx=\"6\" cy=\"18\" r=\"3\"/> <path d=\"M18 9a9 9 0 0 1-9 9\"/>",
|
||
"timer": "<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\"/> <line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\"/> <circle cx=\"12\" cy=\"14\" r=\"8\"/>",
|
||
"calendar-clock": "<path d=\"M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5\"/> <path d=\"M16 2v4\"/> <path d=\"M8 2v4\"/> <path d=\"M3 10h5\"/> <path d=\"M17.5 17.5 16 16.25V14\"/> <path d=\"M22 16a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z\"/>",
|
||
"credit-card": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"5\" rx=\"2\"/> <line x1=\"2\" x2=\"22\" y1=\"10\" y2=\"10\"/>",
|
||
"repeat": "<path d=\"m17 2 4 4-4 4\"/> <path d=\"M3 11v-1a4 4 0 0 1 4-4h14\"/> <path d=\"m7 22-4-4 4-4\"/> <path d=\"M21 13v1a4 4 0 0 1-4 4H3\"/>",
|
||
"user-check": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"/> <circle cx=\"9\" cy=\"7\" r=\"4\"/> <polyline points=\"16 11 18 13 22 9\"/>",
|
||
"alert-triangle": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||
"check": '<polyline points="20 6 9 17 4 12"/>',
|
||
"clipboard-list": '<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>',
|
||
"columns-3": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/>',
|
||
"file-text": '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/>',
|
||
"filter": '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
|
||
"folder": '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||
"image": '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||
"mail": '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||
"megaphone": '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
|
||
"message-square": '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||
"smartphone": '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||
"sparkles": '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>',
|
||
"sticky-note": '<path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M15 3v6h6"/>',
|
||
"table": '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
|
||
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||
"users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||
"workflow": '<rect width="8" height="8" x="3" y="3" rx="2"/><path d="M7 11v4a2 2 0 0 0 2 2h4"/><rect width="8" height="8" x="13" y="13" rx="2"/>',
|
||
"x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||
"zap": '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||
/* Fuer das Menue an der Nachricht, aus dem laufenden Team-Chat. */
|
||
"thumbs-up": '<path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/>',
|
||
"corner-up-left": '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
|
||
"pencil": '<path d="M21.2 6.8a1 1 0 0 0-4-4L3.8 16.2a2 2 0 0 0-.5.8l-1.3 4.4a.5.5 0 0 0 .6.6l4.4-1.3a2 2 0 0 0 .8-.5z"/><path d="m15 5 4 4"/>',
|
||
"trash-2": '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
|
||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||
"rocket": '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>'
|
||
};
|
||
|
||
function feather(name, cx, cy, groesse, farbe, staerke) {
|
||
const s = groesse / 24;
|
||
return '<g transform="translate(' + n1(cx - groesse / 2) + ',' + n1(cy - groesse / 2) +
|
||
') scale(' + n1(s) + ')" fill="none" stroke="' + farbe + '" stroke-width="' +
|
||
n1((staerke == null ? 2 : staerke) / s) +
|
||
'" stroke-linecap="round" stroke-linejoin="round">' + FEATHER[name] + '</g>';
|
||
}
|
||
|
||
/* ═══ 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, ikon, farbe, grund) {
|
||
return '<rect x="' + n1(x) + '" y="' + n1(y) + '" width="' + s + '" height="' + s +
|
||
'" rx="' + n1(s * 0.26) + '" fill="' + grund + '"/>' +
|
||
feather(ikon, x + s / 2, y + s / 2, s * 0.52, farbe, 2.1);
|
||
}
|
||
|
||
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";
|
||
|
||
/* 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: true, pin: true, aktiv: true },
|
||
{ titel: "Sandra Koch", vorschau: "Montagetermin ist mit der Familie abgestimmt.",
|
||
zeit: "13:48", meta: "Projektleitung", art: "direkt", ungelesen: true, 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: true },
|
||
{ 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" },
|
||
];
|
||
|
||
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") {
|
||
const av = AVATAR[c.farbe] || AVATAR.violett;
|
||
g += '<circle cx="' + n1(mx + 20) + '" cy="' + n1(my) + '" r="20" fill="' + av.flaeche +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>' +
|
||
uiText(mx + 20, my + 6, c.kuerzel, 15, 800, av.text, { anker: "middle" });
|
||
} else {
|
||
const projekt = c.art === "projekt";
|
||
g += '<rect x="' + n1(mx) + '" y="' + n1(my - 20) + '" width="40" height="40" rx="11" fill="' +
|
||
(projekt ? "#F0E7FF" : "#FAFAFA") + '" stroke="' + (projekt ? "#e4d4ff" : CHAT_RAND) +
|
||
'" stroke-width="1.2"/>';
|
||
g += feather(projekt ? "briefcase" : "users", mx + 20, my, 20,
|
||
projekt ? "#691CD4" : CHAT_TEXT3, 1.9);
|
||
}
|
||
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 ? "#691CD4" : CHAT_TEXT3, { anker: "end" });
|
||
|
||
// Zeile 2: Vorschau, rechts der Punkt fuer Ungelesenes.
|
||
g += uiText(tx, y + 54, umbrechen(c.vorschau, frei - (c.ungelesen ? 22 : 0), 15, 500, 1)[0],
|
||
15, 500, c.ungelesen ? CHAT_TEXT : CHAT_TEXT2);
|
||
if (c.ungelesen) {
|
||
g += '<circle cx="' + n1(rechts - 6) + '" cy="' + n1(y + 49) + '" r="6" fill="#691CD4"/>';
|
||
}
|
||
|
||
// Zeile 3: die Zuordnung, rechts die Nadel.
|
||
g += uiText(tx, y + 76, c.meta, 13, 500, "#9C9CA3");
|
||
if (c.pin) g += feather("pin", rechts - 7, y + 71, 13, "#9C9CA3", 1.8);
|
||
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, ikon, titel, meta, marke) {
|
||
const zeilen = anhangZeilen([ikon, 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 += feather(ikon, x + 26, y + 27, 17, CHAT_TEXT2, 1.9);
|
||
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)) };
|
||
}
|
||
|
||
/* Wo die zuletzt gezeichnete Anhangkarte lag. Wer einen Rahmen darum
|
||
legen will, liest es direkt nach dem Aufruf von blase() aus -- besser,
|
||
als die Rechnung ein zweites Mal zu fuehren und sie auseinanderlaufen zu
|
||
lassen. */
|
||
let letzterAnhang = null;
|
||
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 += feather("square-check", mx - tb / 2 - 4, y + 12, 16, "#148134", 2);
|
||
});
|
||
return g;
|
||
}
|
||
const ma = blaseMasse(m, blaseBreite(b));
|
||
const bx = m.mein ? x + b - ma.breite : x + 52;
|
||
let g = "";
|
||
if (!m.mein) {
|
||
const av = AVATAR[m.farbe] || AVATAR.blau;
|
||
g += '<circle cx="' + n1(x + 20) + '" cy="' + n1(y + 22) + '" r="20" fill="' + av.flaeche +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>' +
|
||
uiText(x + 20, y + 28, m.kuerzel, 15, 800, av.text, { anker: "middle" });
|
||
}
|
||
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);
|
||
letzterAnhang = { x: bx + 16, y: cy + 4, b: ma.breite - 32, h: ah };
|
||
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="#F0E7FF" ' +
|
||
'stroke="#e4d4ff" stroke-width="1.2"/>';
|
||
g += feather("briefcase", kx + 20, 20, 20, "#691CD4", 1.9);
|
||
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 += feather("plus", kx + 20, fy + 34, 21, CHAT_TEXT2, 2);
|
||
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 += feather("mic", kx + RECHTS - 20, fy + 34, 21, CHAT_TEXT2, 2);
|
||
/* 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);
|
||
const LX = 470; // wo die Liste anfaengt
|
||
const W = LX + sb + m, H = 736;
|
||
let g = "";
|
||
|
||
/* Fest gesetzte Lagen statt gewuerfelter: ein Zufallsgenerator gaebe bei
|
||
jedem Neubau ein anderes Bild, und die Slide soll zweimal gleich
|
||
aussehen. Die ersten beiden liegen vor der Kante, die anderen drei
|
||
dahinter. */
|
||
/* 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 sie der Reihe nach auf und wechselt dabei die
|
||
Seite. Der Anlauf kommt von links ausserhalb, damit sie auf Slide 1
|
||
schon laeuft und nicht erst dort anfaengt. */
|
||
const ANLAUF = 92;
|
||
let pfad = 'M' + n1(-ANLAUF) + ',' + n1(chips[0].y - 34) +
|
||
'C' + n1(-ANLAUF + 46) + ',' + n1(chips[0].y - 30) + ' ' +
|
||
n1(chips[0].x - 34) + ',' + n1(chips[0].y - 16) + ' ' +
|
||
n1(chips[0].x + 14) + ',' + n1(chips[0].y + 27);
|
||
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>' +
|
||
feather(c.ikon, c.x + 28, c.y + 27, 20, CHAT_TEXT3, 1.9) +
|
||
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 - m) + ',' + n1((H - sh) / 2 - m) + ')">' +
|
||
stapel(sb, sh, 20, liste) + '</g>';
|
||
|
||
/* Der Anlauf liegt links vor dem Nullpunkt und setzt oben etwas hoeher
|
||
an als das erste Plaettchen. Der Kasten nimmt beides mit auf -- sonst
|
||
steht die Leitung ausserhalb, und die Randmessung kennt eine
|
||
Zeichnung, die kleiner ist als das, was dasteht. */
|
||
const OBEN = 26;
|
||
return wfSvg(W + ANLAUF, H + OBEN,
|
||
'<g transform="translate(' + ANLAUF + ',' + OBEN + ')">' + 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, rahmen = "";
|
||
for (const m of VERLAUF.slice(2, 5)) {
|
||
inhalt += blase(0, y, W, m);
|
||
/* Der Rahmen um die Aufgabe. Die Machart ist aus den Slack-Beitraegen
|
||
geborgt: ein dicker, runder Rahmen, der ueber die Flaeche hinausragt
|
||
und auf genau eine Sache zeigt. Bei Slack ist er violett, hier gelb
|
||
-- die Marke gibt den Ton vor, nicht das Vorbild.
|
||
|
||
Er sitzt um die Anhangkarte der Aufgabe, weil die den Satz der Slide
|
||
traegt: aus der Nachricht wird etwas mit Frist und Namen. Die Lage
|
||
kommt aus blase() selbst, damit sie nicht auseinanderlaeuft. */
|
||
if (m.anhang && m.anhang[3] === "Offen" && letzterAnhang) {
|
||
/* Seitlich bricht er aus, oben und unten bleibt er knapp: rundum
|
||
gleich viel Luft schnitt den Satz darueber und die Uhrzeit
|
||
darunter mitten durch. Der Ausbruch nach links und rechts ist
|
||
ohnehin der, den man sieht. */
|
||
const a = letzterAnhang, seit = 16, ob = 5, un = 8;
|
||
rahmen = '<path d="' + rundRechteck(a.x - seit, a.y - ob, a.b + 2 * seit,
|
||
a.h + ob + un, 20) +
|
||
'" fill="none" stroke="#F1B729" stroke-width="6" stroke-linejoin="round"/>';
|
||
}
|
||
y += blaseHoehe(m, W) + 18;
|
||
}
|
||
return wfSvg(W, y - 18, inhalt + rahmen);
|
||
}
|
||
|
||
/* ─── Slide 4 · Was noch in den Chat passt ───────────────────────────────
|
||
Das Menue hinter dem Plus im Eingabefeld, woertlich aus Composer. Der
|
||
Trenner sitzt an derselben Stelle: darueber, was man anhaengt, darunter,
|
||
was man anlegt. Deshalb steht er hier auch. */
|
||
const MENUE = [
|
||
["file-text", "Dokument senden"],
|
||
["image", "Fotos und Videos senden"],
|
||
["mic", "Audio senden"],
|
||
["phone", "Kontakt teilen"],
|
||
null,
|
||
["calendar", "Termin erstellen"],
|
||
["square-check", "Aufgabe erstellen"],
|
||
];
|
||
|
||
function visMenue() {
|
||
const W = 470, ZH = 52, PAD = 14, RAND = 30;
|
||
const hoehe = 2 * PAD + MENUE.reduce((a, e) => a + (e ? ZH : 13), 0);
|
||
let g = "";
|
||
let y = PAD + RAND;
|
||
for (const e of MENUE) {
|
||
if (!e) {
|
||
g += '<path d="M' + (PAD + RAND) + ',' + n1(y + 6) + 'H' + n1(W + RAND - PAD) +
|
||
'" stroke="' + CHAT_RAND + '" stroke-width="1.2"/>';
|
||
y += 13; continue;
|
||
}
|
||
g += feather(e[0], PAD + RAND + 26, y + ZH / 2, 20, CHAT_TEXT2, 1.9);
|
||
g += uiText(PAD + RAND + 50, y + ZH / 2 + 7, e[1], 19, 500, CHAT_TEXT);
|
||
y += ZH;
|
||
}
|
||
/* Unter dem Menue stand einmal das Eingabefeld, an dem es haengt. Es ist
|
||
weg: die Slide zeigt, was man anhaengen kann, und dafuer braucht es
|
||
das Feld nicht. Der Stapel sagt ohnehin, dass hier ein Bildschirm
|
||
liegt und nicht ein Kasten im Nichts. */
|
||
return stapelKasten(W + 2 * RAND, hoehe + 2 * RAND, 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" };
|
||
|
||
/* ─── 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 += feather("message-square", 50, 93, 16, CHAT_TEXT3, 1.8);
|
||
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="#F0E7FF" stroke="#e4d4ff" stroke-width="1.2"/>';
|
||
vorn += feather("briefcase", kx + 21, VY + 41, 21, "#691CD4", 1.9);
|
||
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 += feather("plus", VX + 34, fy + 26, 20, CHAT_TEXT2, 2);
|
||
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" im Wechsel. Es liegt ueber beiden
|
||
Platten und ragt beidseitig hinaus -- es sperrt die ganze Sache ab,
|
||
nicht eine Flaeche darin. */
|
||
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;
|
||
let band = '<rect x="' + n1(bA) + '" y="' + n1(ty) + '" width="' + n1(bL) +
|
||
'" height="' + bh + '" fill="' + WEB.band + '"/>' +
|
||
'<path d="M' + n1(bA) + ',' + n1(ty) + 'h' + n1(bL) + '" stroke="' + WEB.bandDunkel +
|
||
'" stroke-width="4"/>' +
|
||
'<path d="M' + n1(bA) + ',' + n1(ty + bh) + 'h' + n1(bL) + '" stroke="' +
|
||
WEB.bandDunkel + '" stroke-width="4"/>';
|
||
const wort = "BALD VERFÜGBAR", gr = 34;
|
||
const wb = messen(wort, gr, 800) + gr * 0.16 * wort.length;
|
||
let bx = bA + 20;
|
||
while (bx + wb + 40 < bA + bL) {
|
||
band += feather("hard-hat", bx + 18, ty + bh / 2, 36, WEB.bandDunkel, 2.6);
|
||
band += uiText(bx + 44, ty + bh / 2 + 12, wort, gr, 800, WEB.bandDunkel,
|
||
{ sperr: "0.16em" });
|
||
bx += wb + 96;
|
||
}
|
||
/* 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. */
|
||
g += '<g transform="rotate(-13,' + n1(W / 2) + ',' + n1(ty + bh / 2) + ')" ' +
|
||
'filter="url(#hgtief)">' + band + '</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,10 ruhig, der Verlauf steht
|
||
0,10 - 0,22 der Zeiger faehrt auf die Nachricht, der Pfeil erscheint
|
||
0,22 - 0,27 Klick
|
||
0,26 - 0,40 das Menue klappt auf, die Zeilen gestaffelt
|
||
0,40 - 0,56 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>';
|
||
|
||
const klickRing = (x, y, p) => p <= 0 || p >= 1 ? "" :
|
||
'<circle cx="' + n1(x) + '" cy="' + n1(y) + '" r="' + n1(7 + 17 * 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 += feather(e[0], x + PAD + 18, yy + ZH / 2, 16, CHAT_TEXT2, 1.9);
|
||
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 * 4.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;
|
||
|
||
const zeigerAn = stufe(t, .10, .22);
|
||
const pfeil = daempfen(stufe(t, .16, .24)); // der Pfeil an der Blase
|
||
const klick1 = stufe(t, .22, .27);
|
||
const auf = daempfen(stufe(t, .26, .40));
|
||
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
|
||
const system = daempfen(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="#F0E7FF" stroke="#e4d4ff" stroke-width="1.2"/>';
|
||
g += feather("briefcase", RAND + 20, RAND + 14, 20, "#691CD4", 1.9);
|
||
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 + '"/>' +
|
||
feather("chevron-down", px, py + 1, 14, CHAT_TEXT2, 2.2) + '</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 += feather("plus", RAND + 14, fy + 44, 21, CHAT_TEXT2, 2);
|
||
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 += feather("mic", B - RAND - 24, fy + 44, 21, CHAT_TEXT2, 2);
|
||
|
||
/* 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 p1 = daempfen(zeigerAn);
|
||
let zx = vonX + (chevX - 2 - vonX) * p1;
|
||
let zy = vonY + (chevY - 2 - vonY) * p1;
|
||
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);
|
||
}
|
||
|
||
/* ═══ Zeitachse ══════════════════════════════════════════════════════════
|
||
Eine Slide mit `dauer` ist animiert. Ihre Zeichenfunktion bekommt dann
|
||
einen zweiten Wert: den Fortschritt t zwischen 0 und 1. Sonst aendert
|
||
sich nichts -- dieselbe Ueberschrift, derselbe Punktgrund, dieselbe
|
||
Schutzzone. Ein animiertes Bild ist hier eine Slide, die sich bewegt,
|
||
und kein zweites Format.
|
||
|
||
ANIM_T haelt je Slide den aktuellen Stand. Das Bauskript setzt ihn ueber
|
||
window.animSetzen() und schiesst dann ein Bild ab; die Vorschau in der
|
||
Seite laesst ihn ueber requestAnimationFrame laufen. Beide gehen durch
|
||
dieselbe Tuer, damit die Vorschau zeigt, was hinterher herauskommt. */
|
||
const ANIM_T = {};
|
||
const animT = j => ANIM_T[j] || 0;
|
||
|
||
/* Ein paar Verlaeufe. Linear sieht nach Maschine aus, deshalb sind Ein-
|
||
und Ausgang gedaempft. `stufe` blendet einen Abschnitt zwischen zwei
|
||
Zeitpunkten ein und liefert 0 davor und 1 danach. */
|
||
const daempfen = x => x <= 0 ? 0 : x >= 1 ? 1 : x < .5
|
||
? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||
const rueck = x => x <= 0 ? 0 : x >= 1 ? 1
|
||
: 1 + 2.7 * Math.pow(x - 1, 3) + 1.7 * Math.pow(x - 1, 2);
|
||
const stufe = (t, von, bis) => t <= von ? 0 : t >= bis ? 1 : (t - von) / (bis - von);
|
||
|
||
const VIS = {
|
||
chat: () => visChat(),
|
||
kanaele: () => visKanaele(),
|
||
aufgabe: () => visAufgabe(),
|
||
menue: () => visMenue(),
|
||
mobil: () => visMobil(),
|
||
animChat: (j, t) => animChat(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 = "post6";
|
||
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);
|
||
|
||
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) => {
|
||
ANIM_T[i] = Math.max(0, Math.min(1, t));
|
||
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;
|
||
const start = performance.now() - ANIM_T[i] * 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. */
|
||
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("section");
|
||
leiste.id = "animleiste";
|
||
(buehne || document.body).appendChild(leiste);
|
||
}
|
||
|
||
const kopf = document.createElement("div");
|
||
kopf.className = "animkopf";
|
||
kopf.innerHTML = "<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");
|
||
for (const path of [...new Set(paths)]) {
|
||
const res = await fetch(path, { cache: "no-store" }).catch(() => null);
|
||
if (!res || !res.ok) {
|
||
if (optional.has(path)) continue;
|
||
throw new Error(path + " -> HTTP " + (res ? res.status : "kein Zugriff"));
|
||
}
|
||
const blob = await res.blob();
|
||
assets[path] = await new Promise((ok, fail) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => ok(fr.result);
|
||
fr.onerror = fail;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
}
|
||
|
||
function press(id, attr, value) {
|
||
document.querySelectorAll("#" + id + " button").forEach(b =>
|
||
b.setAttribute("aria-pressed", String(b.dataset[attr] === value)));
|
||
}
|
||
|
||
function applyAll() {
|
||
/* Erst die Zeichenfarben, dann die Seiten: die Visualisierungen sind in
|
||
diese Farben gezeichnet und muessen bei einem Variantenwechsel neu
|
||
entstehen. In der Skizzenfassung reichte es, nur Titel-Seiten neu zu
|
||
bauen, weil dort alles in einer Farbe stand. */
|
||
farbenNachziehen();
|
||
deck.style.background = variant === "hell" ? "#FFFFFF" : "#30313D";
|
||
deck.querySelectorAll(".slide").forEach((el, i) => {
|
||
el.dataset.variant = variant;
|
||
el.dataset.tilt = tilt;
|
||
el.dataset.bg = bgState[i].bg;
|
||
el.dataset.int = bgState[i].int;
|
||
el.dataset.layout = layoutState[i];
|
||
el.dataset.tagout = tagOut;
|
||
el.dataset.raster = rasterAn;
|
||
el.dataset.mitte = textMitte;
|
||
el.dataset.frame = frameStyle;
|
||
rebuildPage(i);
|
||
el.dataset.editmode = editMode;
|
||
});
|
||
}
|
||
|
||
// The layout catalogue differs per slide, so the buttons are rebuilt on every
|
||
// slide change rather than just re-pressed.
|
||
function syncLayoutButtons() {
|
||
const box = document.getElementById("layouts");
|
||
box.innerHTML = "";
|
||
const cat = SLIDES[current].layouts;
|
||
box.className = "opts" + (cat.length === 1 ? " one" : "");
|
||
cat.forEach(l => {
|
||
const b = document.createElement("button");
|
||
b.dataset.l = l.id;
|
||
b.textContent = l.label;
|
||
b.setAttribute("aria-pressed", String(l.id === layoutState[current]));
|
||
box.appendChild(b);
|
||
});
|
||
}
|
||
|
||
// Reflect the active slide's own ground in the panel.
|
||
function syncBgButtons() {
|
||
press("ints", "i", bgState[current].int);
|
||
}
|
||
|
||
function show(i) {
|
||
current = (i + SLIDES.length) % SLIDES.length;
|
||
saveState();
|
||
deck.querySelectorAll(".holder").forEach((h, n) =>
|
||
h.classList.toggle("sel", n === current));
|
||
document.querySelectorAll("#slides-nav button").forEach((b, n) =>
|
||
b.setAttribute("aria-pressed", String(n === current)));
|
||
syncBgButtons();
|
||
syncLayoutButtons();
|
||
syncInhalt();
|
||
fitStrip();
|
||
metaEl.textContent = view === "streifen"
|
||
? "Slide " + (current + 1) + " ausgewählt. Klick auf eine Slide wechselt die Auswahl"
|
||
: "Slide " + (current + 1) + " von " + SLIDES.length;
|
||
}
|
||
|
||
/* Copy grows, the mockup does not: once the text block reaches the frame the
|
||
slide silently breaks. Rather than discover that in an export, measure it
|
||
and say so. */
|
||
function checkCollisions() {
|
||
const problems = [];
|
||
deck.querySelectorAll(".holder").forEach((holder, i) => {
|
||
const own = holder.querySelector('.page[data-j="' + i + '"]');
|
||
if (!own) return;
|
||
const mock = own.querySelector(".mock");
|
||
if (!mock) return;
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
const content = own.querySelector(".content");
|
||
const gap = mock.offsetTop - (content.offsetTop + content.offsetHeight);
|
||
holder.style.display = restore;
|
||
if (gap < 24) problems.push("Slide " + (i + 1) + " (" + SLIDES[i].eyebrow + "): " + Math.round(gap) + "px");
|
||
});
|
||
if (problems.length) {
|
||
console.warn("Text kollidiert mit dem Mockup:\n" + problems.join("\n"));
|
||
setStatus("Achtung: Text zu nah am Mockup (" + problems.length + "x), siehe Konsole", "err");
|
||
}
|
||
return problems.length === 0;
|
||
}
|
||
|
||
/* Die Bibliothek baut ein SVG in Knotengröße und zieht es beim Zeichnen auf
|
||
die Canvas-Größe hoch. WebKit rasterisiert das SVG dabei in seiner
|
||
*intrinsischen* Größe und interpoliert danach: Ein höherer pixelRatio
|
||
erzeugt so keine zusätzlichen Details, nur weichere Kanten. Genau deshalb
|
||
wirkte 3x nicht schärfer als 1x.
|
||
|
||
Der Ausweg: Das SVG bekommt gleich die Zielgröße über width/height, die
|
||
Slide behält per style ihre echten 1080 x 1350 und wird darin skaliert.
|
||
Die Bibliothek setzt style nach width/height, das Überschreiben greift.
|
||
pixelRatio bleibt 1, weil die Vergrößerung schon im SVG steckt. */
|
||
function renderOptions() {
|
||
const k = superSample;
|
||
return {
|
||
width: 1080 * k,
|
||
height: 1350 * k,
|
||
pixelRatio: 1,
|
||
fontEmbedCSS: fontEmbedCss,
|
||
style: {
|
||
width: "1080px",
|
||
height: "1350px",
|
||
transform: k === 1 ? "none" : "scale(" + k + ")",
|
||
transformOrigin: "top left",
|
||
},
|
||
};
|
||
}
|
||
|
||
/* The browser download route was unreliable here: a data: URL opened a blank
|
||
tab, a blob: URL opened the image in a tab, both times ignoring the download
|
||
attribute. Since this only runs against the local server, the bytes go to
|
||
/save instead and land in the Downloads folder directly. */
|
||
async function downloadBlob(blob, filename) {
|
||
const res = await fetch("/save", {
|
||
method: "POST",
|
||
headers: { "X-Filename": filename },
|
||
body: blob,
|
||
});
|
||
if (!res.ok) throw new Error("Speichern fehlgeschlagen: HTTP " + res.status);
|
||
return (await res.json()).path;
|
||
}
|
||
|
||
// Numeric prefix keeps the upload order intact in the file picker.
|
||
const fileName = i => String(i + 1).padStart(2, "0") + "-" + SLIDES[i].key + "-" + variant + ".png";
|
||
|
||
/* html-to-image serialises the slide into a data:image/svg+xml URL and paints
|
||
that as a single image. Raster children of a foreignObject decode
|
||
asynchronously and can miss that paint, which is why a screenshot went
|
||
missing every few exports while the rest of the slide was fine. It is a
|
||
race, so the fix is not to hope but to verify: render, check that every
|
||
screenshot area actually carries pixels, and repeat if it does not. */
|
||
/* Geprüft wird jede Fläche, die ein Bild tragen muss — die Displays und die
|
||
Geräterahmen selbst. Die Rahmen fehlten in dieser Liste, deshalb blieb ein
|
||
Export ohne Macbook unbemerkt. */
|
||
const SHOT_BOXES =
|
||
".mock-inner, .laptop-screen, .hex-shot, .szene-dev, .laptop-frame";
|
||
const MAX_TRIES = 5;
|
||
// Ab hier gilt eine Fläche als leer. Da nur noch das Innere des Vierecks
|
||
// abgetastet wird, ist kein Beiwerk mehr dabei, das die Spanne aufbläht.
|
||
const LEER_SPANNE = 6;
|
||
|
||
/* Die vier Ecken eines Kindes in Slide-Koordinaten.
|
||
Vorher stand hier das achsenparallele Umrechteck. Das war der Fehler:
|
||
ein gedrehtes oder perspektivisch gestelltes Bild hat ein Umrechteck, das
|
||
Raster, Hintergrund und Nachbarelemente mit einschliesst. Darin fand die
|
||
Prüfung immer genug Helligkeitsspanne und winkte eine leere Fläche durch —
|
||
genau so kam ein Export ohne Screenshots durch die Kontrolle.
|
||
getBoxQuads gäbe die echten Ecken, kennt Chrome aber nicht. Also werden
|
||
vier 1-px-Marken in die Ecken gesetzt und ausgemessen: die Layout-Engine
|
||
rechnet dabei jede Transformation der Vorfahren korrekt mit. Die Marken
|
||
verschwinden vor dem Rendern wieder. */
|
||
function quadInSlide(el, child) {
|
||
const sr = el.getBoundingClientRect();
|
||
const k = sr.width / 1080;
|
||
let host = child, stell = null;
|
||
/* <img> kann keine Kinder tragen. Also entsteht im Elternknoten ein
|
||
deckungsgleicher Stellvertreter — mit derselben Box UND derselben
|
||
eigenen Transformation. Ohne letztere fehlt genau der Versatz
|
||
verschobener Bilder: .mock-inner > img wird per scale und translate in
|
||
seinem Fenster verschoben, das Viereck läge sonst daneben und die
|
||
Messung geriete auf Rahmen statt auf Bild. */
|
||
if (child.tagName === "IMG") {
|
||
const eltern = child.offsetParent || child.parentElement;
|
||
if (!eltern) return null;
|
||
const cs = getComputedStyle(child);
|
||
stell = document.createElement("div");
|
||
stell.style.cssText = "position:absolute;margin:0;padding:0;border:0;" +
|
||
"pointer-events:none;left:" + child.offsetLeft + "px;top:" + child.offsetTop +
|
||
"px;width:" + child.offsetWidth + "px;height:" + child.offsetHeight +
|
||
"px;transform:" + cs.transform + ";transform-origin:" + cs.transformOrigin + ";";
|
||
eltern.appendChild(stell);
|
||
host = stell;
|
||
}
|
||
const bw = host.offsetWidth, bh = host.offsetHeight;
|
||
if (bw < 4 || bh < 4) { if (stell) stell.remove(); return null; }
|
||
// Absolute Marken brauchen einen positionierten Bezug.
|
||
const posVorher = host.style.position;
|
||
if (getComputedStyle(host).position === "static") host.style.position = "relative";
|
||
const marken = [[0, 0], [1, 0], [1, 1], [0, 1]].map(([fx, fy]) => {
|
||
const m = document.createElement("div");
|
||
m.style.cssText = "position:absolute;margin:0;padding:0;border:0;width:1px;" +
|
||
"height:1px;pointer-events:none;left:" + (fx * bw) + "px;top:" + (fy * bh) + "px;";
|
||
host.appendChild(m);
|
||
return m;
|
||
});
|
||
const pts = marken.map(m => {
|
||
const r = m.getBoundingClientRect();
|
||
return { x: ((r.left + r.width / 2) - sr.left) / k,
|
||
y: ((r.top + r.height / 2) - sr.top) / k };
|
||
});
|
||
marken.forEach(m => m.remove());
|
||
host.style.position = posVorher;
|
||
if (stell) stell.remove();
|
||
return pts;
|
||
}
|
||
|
||
/* A screenshot area is never a flat surface: it carries UI, text and colour.
|
||
If the sampled pixels are all but identical, the image did not make it. */
|
||
/* Ein fehlendes Bild hinterlässt die reine Hintergrundfarbe des Rahmens,
|
||
also eine vollkommen gleichmäßige Fläche. Genau darauf wird geprüft, nicht
|
||
auf "genug Kontrast": Eine helle Formular-Oberfläche hat wenig Spanne, ist
|
||
aber nie gleichmäßig. Ein zu hoher Schwellenwert hat sie fälschlich als
|
||
fehlend gemeldet. */
|
||
const quadMitte = q => ({ x: (q[0].x + q[1].x + q[2].x + q[3].x) / 4,
|
||
y: (q[0].y + q[1].y + q[2].y + q[3].y) / 4 });
|
||
|
||
function quadEinruecken(q, f) {
|
||
const m = quadMitte(q);
|
||
return q.map(p => ({ x: p.x + (m.x - p.x) * f, y: p.y + (m.y - p.y) * f }));
|
||
}
|
||
|
||
function quadFlaeche(q) {
|
||
let s = 0;
|
||
for (let i = 0; i < 4; i++) { const a = q[i], b = q[(i + 1) % 4]; s += a.x * b.y - b.x * a.y; }
|
||
return Math.abs(s) / 2;
|
||
}
|
||
|
||
function quadEnthaelt(q, p) {
|
||
let pos = 0, neg = 0;
|
||
for (let i = 0; i < 4; i++) {
|
||
const a = q[i], b = q[(i + 1) % 4];
|
||
const d = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
|
||
if (d > 0) pos++; else if (d < 0) neg++;
|
||
}
|
||
return pos === 0 || neg === 0;
|
||
}
|
||
|
||
/* Messpunkte dort, wo Bild und Rahmen sich überlappen — und nur dort.
|
||
Beides einzeln führt in die Irre: Das Szenen-Display ist im Entwurfsraum
|
||
größer als der sichtbare Ausschnitt, weil Deckelblende, Tastaturschale und
|
||
das davorstehende iPad Teile verdecken; diese Randzonen tragen Kontrast
|
||
und täuschen ein vorhandenes Bild vor. Beim Feature-Mockup ist es
|
||
umgekehrt: das gezoomte Bild ragt weit über sein Fenster hinaus, dort
|
||
läge die Messung außerhalb der Slide. Das Raster wird über das kleinere
|
||
der beiden Vierecke gelegt und gegen das größere geprüft. */
|
||
function ueberlappPunkte(qBox, qImg) {
|
||
const A = quadEinruecken(qBox, 0.08), B = quadEinruecken(qImg, 0.08);
|
||
const raster = quadFlaeche(A) <= quadFlaeche(B) ? A : B;
|
||
const gegen = raster === A ? B : A;
|
||
const pts = [];
|
||
for (let a = 0; a <= 16; a++) {
|
||
for (let b = 0; b <= 16; b++) {
|
||
const u = a / 16, v = b / 16;
|
||
const tx = raster[0].x + (raster[1].x - raster[0].x) * u;
|
||
const ty = raster[0].y + (raster[1].y - raster[0].y) * u;
|
||
const bx = raster[3].x + (raster[2].x - raster[3].x) * u;
|
||
const by = raster[3].y + (raster[2].y - raster[3].y) * u;
|
||
const p = { x: Math.round(tx + (bx - tx) * v), y: Math.round(ty + (by - ty) * v) };
|
||
if (quadEnthaelt(gegen, p)) pts.push(p);
|
||
}
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function spanneAn(lum, pts) {
|
||
let min = 255, max = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < min) min = l;
|
||
if (l > max) max = l;
|
||
n++;
|
||
}
|
||
// Zu wenige Punkte in der Slide: nichts zu beurteilen, also nicht bemängeln.
|
||
return n < 20 ? 255 : max - min;
|
||
}
|
||
|
||
/* Der Anteil dunkler Punkte, den eine Vorlage auf weißem Grund erzeugt.
|
||
Für die Geräterahmen ist Gleichmäßigkeit allein kein brauchbares Merkmal:
|
||
fehlt der Rahmen, bleiben die Rasterlinien des Hintergrunds stehen und
|
||
täuschen Spanne vor. Ein fehlendes Macbook lässt aber eine helle Fläche
|
||
zurück, und der Erwartungswert dafür wird aus der Vorlage selbst geeicht,
|
||
damit kein fester Schwellenwert an einer bestimmten Grafik klebt. */
|
||
function dunkelAnteilVorlage(im) {
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = 48;
|
||
const g = c.getContext("2d", { willReadFrequently: true });
|
||
g.fillStyle = "#fff";
|
||
g.fillRect(0, 0, 48, 48);
|
||
try { g.drawImage(im, 0, 0, 48, 48); } catch (_) { return 0; }
|
||
const d = g.getImageData(0, 0, 48, 48).data;
|
||
let dunkel = 0;
|
||
for (let i = 0; i < d.length; i += 4)
|
||
if ((d[i] * 299 + d[i + 1] * 587 + d[i + 2] * 114) / 1000 < 140) dunkel++;
|
||
return dunkel / (48 * 48);
|
||
}
|
||
|
||
function dunkelAn(lum, pts) {
|
||
let dunkel = 0, n = 0;
|
||
for (const p of pts) {
|
||
const l = lum(p.x, p.y);
|
||
if (l < 0) continue;
|
||
if (l < 140) dunkel++;
|
||
n++;
|
||
}
|
||
return n < 20 ? 1 : dunkel / n;
|
||
}
|
||
|
||
async function renderSlide(i) {
|
||
// Hilfslinien sind Werkzeug: koennten sie aus einem Tastendruck noch
|
||
// stehen, landeten sie im Bild.
|
||
clearTimeout(nudgeTimer); hideGuides();
|
||
const holder = deck.querySelectorAll(".holder")[i];
|
||
const el = holder.querySelector(".slide");
|
||
// In der Einzelansicht sind die übrigen Holder ausgeblendet; zum Messen und
|
||
// Rendern muss der Holder kurz sichtbar sein.
|
||
const restore = holder.style.display;
|
||
holder.style.display = "block";
|
||
// Auswahlrahmen und Hover-Kontur sind Werkzeug, kein Inhalt: für den Export
|
||
// abschalten, sonst wird die gelbe Umrandung mitgerendert.
|
||
const modeBefore = el.dataset.editmode;
|
||
el.dataset.editmode = "aus";
|
||
try {
|
||
await document.fonts.ready;
|
||
const imgs = [...el.querySelectorAll("img")];
|
||
await Promise.all(imgs.map(im =>
|
||
im.complete ? im.decode().catch(() => {})
|
||
: new Promise(r => { im.onload = r; im.onerror = r; })));
|
||
const broken = imgs.filter(im => !im.naturalWidth);
|
||
if (broken.length) throw new Error(broken.length + " Bild(er) nicht geladen");
|
||
|
||
/* Jede prüfpflichtige Fläche mit ihrem Viereck und, bei Geräterahmen,
|
||
mit dem aus der Vorlage geeichten Erwartungswert. */
|
||
const ziele = [...el.querySelectorAll(SHOT_BOXES)].map(c => {
|
||
// Der Geräterahmen ist selbst ein Bild; sonst steckt es im Rahmen.
|
||
const rahmen = c.tagName === "IMG";
|
||
const bild = rahmen ? c : c.querySelector("img");
|
||
if (!bild) return null;
|
||
const qBox = quadInSlide(el, rahmen ? c.parentElement : c);
|
||
const qImg = quadInSlide(el, bild);
|
||
if (!qBox || !qImg) return null;
|
||
return {
|
||
name: c.className || c.tagName.toLowerCase(),
|
||
pts: ueberlappPunkte(qBox, qImg),
|
||
rahmen,
|
||
erwartetDunkel: rahmen ? dunkelAnteilVorlage(bild) : 0,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
|
||
/* Die Slide entsteht bei Renderqualität k in 1080k × 1350k, und genau
|
||
das wird auch gespeichert. Hier stand ein downscaleTo auf 1080 × 1350
|
||
— das warf bei k = 3 zwei Drittel der linearen Auflösung wieder weg.
|
||
Eine Vorlage mit 2390 px landete dadurch mit 916 px im Ergebnis,
|
||
obwohl sie schon in 2748 px gerendert vorlag. Wer eine hoch
|
||
aufgelöste Aufnahme liefert, soll sie auch im Export wiederfinden;
|
||
kleiner rechnen kann jedes Zielsystem selbst. */
|
||
const canvas = await htmlToImage.toCanvas(el, renderOptions());
|
||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||
// Die Messpunkte stehen in Slide-Koordinaten (1080 breit) und werden
|
||
// auf die tatsächliche Ausgabegröße umgerechnet.
|
||
const AW = canvas.width, AH = canvas.height, af = AW / 1080;
|
||
const px = ctx.getImageData(0, 0, AW, AH).data;
|
||
const lum = (x, y) => {
|
||
const sx = Math.round(x * af), sy = Math.round(y * af);
|
||
if (sx < 0 || sy < 0 || sx >= AW || sy >= AH) return -1;
|
||
const j = (sy * AW + sx) * 4;
|
||
return (px[j] * 299 + px[j + 1] * 587 + px[j + 2] * 114) / 1000;
|
||
};
|
||
|
||
const befund = ziele.map(z => {
|
||
const spanne = spanneAn(lum, z.pts);
|
||
// Eine leere Fläche trägt die reine Hintergrundfarbe ihres Rahmens.
|
||
let fehlt = spanne <= LEER_SPANNE;
|
||
let dunkel = null;
|
||
// Beim Geräterahmen greift Gleichmäßigkeit nicht: fehlt er, bleibt
|
||
// der gerasterte Hintergrund stehen. Dann zählt, wie viel Dunkles
|
||
// ankommt, gemessen gegen die Vorlage.
|
||
if (!fehlt && z.rahmen && z.erwartetDunkel >= 0.08) {
|
||
dunkel = dunkelAn(lum, z.pts);
|
||
if (dunkel < z.erwartetDunkel * 0.5) fehlt = true;
|
||
}
|
||
return { name: z.name, spanne, dunkel, erwartet: z.erwartetDunkel, fehlt };
|
||
});
|
||
const fehlend = befund.filter(b => b.fehlt);
|
||
|
||
if (!fehlend.length) {
|
||
const blob = await new Promise(r => canvas.toBlob(r, "image/png"));
|
||
if (blob) return blob;
|
||
}
|
||
if (attempt < MAX_TRIES) {
|
||
console.warn("Slide " + (i + 1) + ": Versuch " + attempt + " unvollstaendig. " +
|
||
befund.map(b => b.name + " Spanne " + b.spanne.toFixed(1) +
|
||
(b.dunkel === null ? "" : ", dunkel " + b.dunkel.toFixed(3) +
|
||
" erwartet " + b.erwartet.toFixed(3)) + (b.fehlt ? " <== leer" : "")).join(" | "));
|
||
setStatus("Slide " + (i + 1) + ": " + fehlend.map(b => b.name).join(", ") +
|
||
" fehlte, Versuch " + (attempt + 1) + " ...");
|
||
await new Promise(r => setTimeout(r, 180 * attempt));
|
||
}
|
||
}
|
||
throw new Error("Slide " + (i + 1) + " nach " + MAX_TRIES +
|
||
" Versuchen unvollstaendig. Details in der Konsole.");
|
||
} finally {
|
||
el.dataset.editmode = modeBefore;
|
||
holder.style.display = restore;
|
||
}
|
||
}
|
||
|
||
// Die Ausgabegröße hängt an der Renderqualität und wird mitgesagt, damit
|
||
// sichtbar ist, was tatsächlich auf der Platte landet.
|
||
const ausgabeGroesse = () => (1080 * superSample) + " × " + (1350 * superSample) + " px";
|
||
|
||
async function downloadOne() {
|
||
setStatus("Rendere Slide " + (current + 1) + " …");
|
||
downloadBlob(await renderSlide(current), fileName(current));
|
||
setStatus("Slide " + (current + 1) + " gespeichert, " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
async function downloadAll() {
|
||
const zip = new JSZip();
|
||
for (let i = 0; i < SLIDES.length; i++) {
|
||
setStatus("Rendere Slide " + (i + 1) + " von " + SLIDES.length + " …");
|
||
zip.file(fileName(i), await renderSlide(i));
|
||
}
|
||
show(current);
|
||
setStatus("Packe ZIP …");
|
||
/* Der Name kommt aus PROJECT und nicht aus einer festen Zeichenkette.
|
||
Vorher stand hier "hivegrid-post1-loesung" -- in allen Projektdateien,
|
||
weil sie voneinander kopiert wurden. Post 2 und Post 3 haben ihre ZIPs
|
||
damit unter dem Namen von Post 1 abgelegt, und im Downloads-Ordner lagen
|
||
drei verschiedene Beitraege unter einem Namen. */
|
||
const path = await downloadBlob(await zip.generateAsync({ type: "blob" }),
|
||
"hivegrid-" + PROJECT + "-" + variant + ".zip");
|
||
setStatus("ZIP gespeichert: " + path.split("/").pop() + ", " + ausgabeGroesse(), "ok");
|
||
}
|
||
|
||
const guard = fn => () => fn().catch(err => {
|
||
console.error(err);
|
||
setStatus("Fehler: " + err.message, "err");
|
||
});
|
||
|
||
/* ─── Slides verwalten ────────────────────────────────────────────────────
|
||
Die Felder pro Slide (Verschiebungen, Muster, Layout) haengen an der
|
||
Anzahl und muessen bei jeder Aenderung im Gleichschritt mitwandern. Das
|
||
laeuft ueber slideListe als Bezugsgroesse, nicht ueber SLIDES: SLIDES
|
||
wird daraus jedes Mal neu berechnet. */
|
||
function passeFelderAn() {
|
||
const n = SLIDES.length;
|
||
while (edits.length < n) edits.push({});
|
||
edits.length = n;
|
||
while (bgState.length < n) bgState.push({ bg: "formen", int: "dezent" });
|
||
bgState.length = n;
|
||
while (layoutState.length < n) layoutState.push(SLIDES[layoutState.length].layouts[0].id);
|
||
layoutState.length = n;
|
||
// Ein Layout, das es auf dieser Slide nicht gibt, faellt auf das erste
|
||
// zurueck: sonst zeigt eine Feature-Slide ein Titel-Layout.
|
||
layoutState.forEach((id, i) => {
|
||
if (!SLIDES[i].layouts.some(l => l.id === id)) layoutState[i] = SLIDES[i].layouts[0].id;
|
||
});
|
||
}
|
||
|
||
function deckNeuBauen() {
|
||
deck.innerHTML = "";
|
||
nav.innerHTML = "";
|
||
buildDeck();
|
||
applyAll();
|
||
applyEditsAll();
|
||
}
|
||
|
||
function anzahlNachziehen() {
|
||
const n = SLIDES.length;
|
||
const kopf = document.getElementById("kopfzeile");
|
||
if (kopf) kopf.textContent =
|
||
(n === 1 ? "Einzelbild" : "Carousel, " + n + " Slides") + " · 1080 × 1350 px";
|
||
const zip = document.getElementById("dl-all");
|
||
if (zip) {
|
||
zip.textContent = "Alle " + n + " Slides als ZIP";
|
||
// Bei einer einzigen Slide ist ein ZIP nur ein Umweg.
|
||
zip.style.display = n > 1 ? "" : "none";
|
||
}
|
||
// Das durchgehende Blatt ist so breit wie der Post lang ist.
|
||
deck.style.setProperty("--sheet-w", (n * 1080) + "px");
|
||
}
|
||
|
||
function nachDeckAenderung(an, meldung) {
|
||
slidesNeuAufbauen();
|
||
passeFelderAn();
|
||
picked = null;
|
||
deckNeuBauen();
|
||
current = Math.max(0, Math.min(an, SLIDES.length - 1));
|
||
show(current);
|
||
anzahlNachziehen();
|
||
syncInhalt();
|
||
syncEditPanel();
|
||
saveState();
|
||
if (meldung) setStatus(meldung, "ok");
|
||
}
|
||
|
||
function slideEinfuegen(eintrag, an) {
|
||
slideListe.splice(an, 0, eintrag);
|
||
edits.splice(an, 0, {});
|
||
bgState.splice(an, 0, kopie(bgState[current] || { bg: "formen", int: "dezent" }));
|
||
slidesNeuAufbauen();
|
||
layoutState.splice(an, 0, SLIDES[an].layouts[0].id);
|
||
}
|
||
|
||
function slideNeu() {
|
||
const an = current + 1;
|
||
slideEinfuegen({ werte: {} }, an);
|
||
nachDeckAenderung(an, "Slide " + (an + 1) + " angelegt");
|
||
}
|
||
|
||
function slideKopieren() {
|
||
const an = current + 1;
|
||
const q = slideListe[current];
|
||
slideEinfuegen({ ref: q.ref, key: q.key, werte: kopie(q.werte || {}) }, an);
|
||
// Auch die Feinarbeit mitnehmen, sonst ist die Kopie nur halb eine.
|
||
edits[an] = kopie(edits[current]);
|
||
bgState[an] = kopie(bgState[current]);
|
||
layoutState[an] = layoutState[current];
|
||
nachDeckAenderung(an, "Slide kopiert");
|
||
}
|
||
|
||
function slideLoeschen() {
|
||
if (SLIDES.length <= 1) { setStatus("Die letzte Slide bleibt stehen", "err"); return; }
|
||
const weg = current;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => a.splice(weg, 1));
|
||
nachDeckAenderung(Math.min(weg, SLIDES.length - 2), "Slide gelöscht");
|
||
}
|
||
|
||
function slideSchieben(um) {
|
||
const ziel = current + um;
|
||
if (ziel < 0 || ziel >= SLIDES.length) return;
|
||
[slideListe, edits, bgState, layoutState].forEach(a => {
|
||
const [x] = a.splice(current, 1);
|
||
a.splice(ziel, 0, x);
|
||
});
|
||
nachDeckAenderung(ziel, "Slide verschoben");
|
||
}
|
||
|
||
/* ─── Inhalt der gewaehlten Slide ─────────────────────────────────────────
|
||
Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht
|
||
aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt
|
||
Aenderungen an der Vorlage im Code weiterhin mit. */
|
||
/* ─── Eigene Bilder ───────────────────────────────────────────────────────
|
||
Die Datei geht unveraendert an den Server und liegt danach unter
|
||
assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere
|
||
ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff
|
||
unbrauchbar. So steht dort nur der Pfad. */
|
||
function bildOptionen() {
|
||
return BILDER.map(b =>
|
||
'<option value="' + b.pfad + '">' +
|
||
(b.ordner === "eigene" ? "★ " : "") + b.name + '</option>').join("");
|
||
}
|
||
|
||
async function bilderNachladen() {
|
||
try {
|
||
const res = await fetch("/bilder", { cache: "no-store" });
|
||
if (!res.ok) return;
|
||
const liste = await res.json();
|
||
if (Array.isArray(liste) && liste.length) BILDER = liste;
|
||
} catch (_) { /* ohne Server bleibt die eingebaute Liste */ }
|
||
}
|
||
|
||
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
|
||
async function bildBereitstellen(pfad) {
|
||
if (assets[pfad]) return true;
|
||
const res = await fetch(pfad, { cache: "no-store" }).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>
|