20-02-2026
BIN
frontend/dev/19-02-2026/6aac395fbacf32e19096aa404c0f9d4b.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 987 KiB |
BIN
frontend/dev/19-02-2026/c312dc2d46f8c869160e1e65e6f1d54e.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
59
frontend/dev/19-02-2026/dependency-check.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Dependency Check — 19.02.2026
|
||||
|
||||
## Umgebung
|
||||
|
||||
- **Node.js:** v22.20.0
|
||||
- **npm:** 11.6.2
|
||||
|
||||
## Versionsvergleich
|
||||
|
||||
| Paket | package.json (Range) | Installiert | Latest | Status |
|
||||
|-------------------------------|----------------------|-------------|---------|----------------|
|
||||
| **quasar** | ^2.16.0 | 2.18.1 | 2.18.6 | Update möglich |
|
||||
| **@quasar/app-vite** | ^2.1.0 | 2.2.0 | 2.4.1 | Update möglich |
|
||||
| **@quasar/extras** | ^1.16.4 | 1.16.17 | 1.17.0 | Update möglich |
|
||||
| **vue** | ^3.4.18 | 3.5.13 | 3.5.28 | Update möglich |
|
||||
| **vue-router** | ^4.0.0 | 4.5.0 | 5.0.2 | Major verfügbar (Breaking!) |
|
||||
| **pinia** | ^3.0.1 | 3.0.1 | 3.0.4 | Update möglich |
|
||||
| **eslint** | ^9.14.0 | 9.37.0 | 10.0.0 | Major verfügbar |
|
||||
| **@eslint/js** | ^9.14.0 | 9.37.0 | 10.0.1 | Major verfügbar |
|
||||
| **eslint-plugin-vue** | ^9.30.0 | 9.33.0 | 10.8.0 | Major verfügbar |
|
||||
| **prettier** | ^3.3.3 | 3.5.3 | 3.8.1 | Update möglich |
|
||||
| **vite-plugin-checker** | ^0.9.0 | 0.9.1 | 0.12.0 | Update möglich |
|
||||
| **@vue/eslint-config-prettier** | ^10.1.0 | 10.2.0 | 10.2.0 | Aktuell |
|
||||
| **globals** | ^15.12.0 | 15.15.0 | 17.3.0 | Major verfügbar |
|
||||
| **autoprefixer** | ^10.4.2 | 10.4.21 | 10.4.24 | Update möglich |
|
||||
| **postcss** | ^8.4.14 | 8.5.3 | 8.5.6 | Update möglich |
|
||||
|
||||
## Bewertung
|
||||
|
||||
### Sichere Minor/Patch-Updates (empfohlen)
|
||||
|
||||
Diese Updates sind innerhalb der bestehenden semver-Ranges und sollten problemlos funktionieren:
|
||||
|
||||
```bash
|
||||
npm update
|
||||
```
|
||||
|
||||
Das aktualisiert: quasar, vue, pinia, prettier, autoprefixer, postcss, @quasar/extras, vite-plugin-checker
|
||||
|
||||
### Updates mit Range-Anpassung (empfohlen)
|
||||
|
||||
Diese erfordern eine Änderung in `package.json`, sind aber Minor-Updates ohne Breaking Changes:
|
||||
|
||||
- `@quasar/app-vite`: ^2.1.0 → ^2.4.1 (signifikante Verbesserungen im Build-Tooling)
|
||||
|
||||
### Major-Updates (Vorsicht!)
|
||||
|
||||
Diese haben potenziell Breaking Changes und sollten einzeln getestet werden:
|
||||
|
||||
- **vue-router 5.0.2** — Major-Release, erfordert Migrations-Arbeit
|
||||
- **eslint 10.0.0 / @eslint/js 10.0.1** — Neue Major, Config-Änderungen möglich
|
||||
- **eslint-plugin-vue 10.x** — Abgestimmt auf eslint 10
|
||||
- **globals 17.x** — Major-Sprung von 15.x
|
||||
|
||||
## Empfehlung
|
||||
|
||||
1. Zuerst `npm update` ausführen für sichere Patch/Minor-Updates
|
||||
2. `@quasar/app-vite` manuell auf ^2.4.1 anheben
|
||||
3. Major-Updates (vue-router 5, eslint 10) separat evaluieren — nicht zusammen upgraden
|
||||
BIN
frontend/dev/19-02-2026/e37861bce54b932c73be79cd8dfdaf96.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/dev/19-02-2026/e699b3edd8b9dedfb2232ef3428b8fc7.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
48
frontend/dev/19-02-2026/konzept.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
### **Der "Thats Me" Wow-Prototyp**
|
||||
|
||||
**1. Die Kernphilosophie: "Funktionale Eleganz"**
|
||||
|
||||
Unser Design ist nicht nur Dekoration; es ist eine Form der Datenvisualisierung. Jedes visuelle Element hat eine Funktion und leitet sich direkt aus den Emotionen und der Persönlichkeit des Nutzers ab. Wir schaffen eine minimalistische "Bühne", auf der die Erinnerungen des Nutzers die leuchtenden Hauptdarsteller sind.
|
||||
|
||||
**2. Visuelle Säule 1: Das UI-Grundgerüst (Die Bühne)**
|
||||
|
||||
- **Look & Feel:** Minimalistisch, modern, aufgeräumt. Der Fokus liegt zu 100% auf der LifeWave.
|
||||
- **Hintergrund:** Ein reiner, neutraler Hintergrund (Hell: Weiß/Beige, Dunkel: Tiefes Grau/Schwarz) - Hell- und Dunkel Modus.
|
||||
- **Interaktive Elemente (Menüs & Panels):** Wir verwenden einen "Glassmorphism" / "Frosted Glass"-Look, genau wie in den Bildern /workspace/frontend/dev/19-02-2026/e699b3edd8b9dedfb2232ef3428b8fc7.jpg und frontend/dev/19-02-2026/c312dc2d46f8c869160e1e65e6f1d54e.jpg
|
||||
- Alle Menüs (das User-Menü) und der von unten kommende Slide-Up-Panel (zur Event-Eingabe) nutzen diesen Effekt.
|
||||
- **Der "Wow-Effekt" hierbei:** Wenn das Panel hochfährt, bleibt die LifeWave im Hintergrund sichtbar, wird aber durch das "Glas" wunderschön diffus und unscharf. Das schafft Tiefe und erhält den Kontext.
|
||||
- **Navigation:** Extrem reduziert.
|
||||
- **Oben:** Ein User-Icon (dient als Menü-Button und Login-Status).
|
||||
- **Unten:** Ein einzelner, schwebender "+" Button (ebenfalls im "Glass-Look"), um ein Event zu starten.
|
||||
|
||||
**3. Visuelle Säule 2: Die Events (Die "Glow-Punkte")**
|
||||
|
||||
Hier kodieren wir die Emotionen und die Persönlichkeit.
|
||||
|
||||
- **Form:** Jedes Event ist ein minimalistischer Kreis.
|
||||
- **Farbe & Emotion (Das Kernkonzept):** Die Farbe eines Events wird durch einen leuchtenden "Glow" (eine Aura) dargestellt, nicht durch eine flächige Füllung.
|
||||
- Die Bilder frontend/dev/19-02-2026/e37861bce54b932c73be79cd8dfdaf96.jpg (warm/positiv) und frontend/dev/19-02-2026/6aac395fbacf32e19096aa404c0f9d4b.jpg (kühl/negativ) sind hierfür die perfekte Referenz.
|
||||
- **Die "Default + Custom"-Logik:**
|
||||
1. **Default (Der Automatismus):** Die Emotion (Positiv/Negativ) steuert ZWEI Dinge: die **Y-Achse** (Höhe auf der Welle) und die **Farbe des Glows** (z.B. Positiv = Warmes Grün/Gelb, Negativ = Kühles Blau/Rot).
|
||||
2. **Custom (Der Stempel):** Der Nutzer kann die Default-Farbe JEDERZEIT über einen Color-Picker mit seiner persönlichen "Wunschfarbe" überschreiben. Die Y-Position (Emotion) bleibt davon unberührt.
|
||||
|
||||
**4. Visuelle Säule 3: Die LifeWave (Der "Energiestrom")**
|
||||
|
||||
Das ist das Bindeglied, das die Geschichte erzählt.
|
||||
|
||||
- **Form:** Die Welle ist kein einfacher Strich. Sie ist ein organischer, dynamischer "Energiestrom", der aus vielen feinen Linien besteht, Sie muss sich lebendig und fließend anfühlen. Sie muss Einstellungen haben, indem der User seine Wave selbst anpassen kann mit Krümmung, Farben, Vielfalt dazu habe ich ein super Beispiel gefunden, welches in React umgesetzt ist und welches ich fast genauso adaptieren würde, lokal untner frontend/dev/floating-lines.js, frontend/dev/init-fl.html
|
||||
- **Farbe (Der "Emotionale Fluss"):** Die Welle ist ein **Verlauf**. Sie nimmt die finale "Glow"-Farbe des Start-Events auf und verläuft fließend zur "Glow"-Farbe des nächsten Events.
|
||||
- **Beispiel:** Geht ein "Positiv (Grüner Glow)"-Event in ein "Custom (Pinker Glow)"-Event über, erzeugt die Welle dazwischen einen wunderschönen Verlauf von Grün nach Pink.
|
||||
|
||||
**5. Das Zusammenspiel (Der Prototyp-Flow)**
|
||||
|
||||
Das "Wow"-Erlebnis des Prototyps wird wie folgt visualisiert:
|
||||
|
||||
1. **Die Szene:** Der Nutzer sieht eine minimalistische, weiße Leinwand. Darauf schweben 3-4 "Glow-Punkte", verbunden durch die dynamische, farbverlaufende Welle.
|
||||
2. **Die Interaktion:** Der Nutzer tippt auf den "Glass Look" `+`Button.
|
||||
3. **Das Panel:** Der "Frosted Glass"-Panel fährt von unten hoch. Die "Glow-Punkte" und die Welle im Hintergrund werden sanft unscharf.
|
||||
4. **Die Magie:** Der Nutzer füllt die Felder aus. Er bewegt den **Emotions-Regler** auf "Positiv".
|
||||
5. **Das Live-Feedback:** Im Hintergrund (über dem Panel) sieht der Nutzer _in Echtzeit_, wie ein neuer "Glow-Punkt" entsteht, auf der Y-Achse nach oben wandert und einen warmen, grünen Glow bekommt. Die Welle verbindet sich dynamisch mit dem neuen Punkt.
|
||||
6. **Der Stempel:** Der Nutzer tippt auf "Farbe anpassen", wählt ein "Lila oder Lila Verlauf". Der Glow des Punktes ändert sich _sofort_ von Grün zu Lila. Die Welle passt ihren Verlauf an.
|
||||
|
||||
Dieses Konzept liefert einen klaren visuellen Haken, ist extrem modern und setzt unsere Kernidee – Emotion in Design zu verwandeln – perfekt um.
|
||||
372
frontend/dev/19-02-2026/umsetzungskonzept.md
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
# Umsetzungskonzept — "That's Me" Wow-Prototyp
|
||||
|
||||
Dieses Dokument beschreibt die schrittweise Umsetzung des Prototyps. Jeder Schritt baut auf dem vorherigen auf und ist einzeln testbar.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Viewport & Grundgerüst
|
||||
|
||||
### 1.1 — Neues App-Layout (`LifeWaveLayout.vue`)
|
||||
|
||||
**Ziel:** Minimalistisches Fullscreen-Layout als "Bühne" — ersetzt das bestehende MainLayout für die LifeWave-Ansicht.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Neues Layout `src/layouts/LifeWaveLayout.vue` erstellen
|
||||
- Fullscreen-Viewport (`100dvh`) ohne Quasar-Header/Footer/Drawer
|
||||
- Neutraler Hintergrund: Hell-Modus `#FAFAFA`, Dunkel-Modus `#0A0A0A`
|
||||
- CSS Custom Properties für Theme-Switching (`--bg-primary`, `--bg-glass`, `--text-primary`)
|
||||
- Slot-basiert: `<router-view />` füllt den gesamten Viewport
|
||||
- Kein Scrolling auf der Hauptebene — alles innerhalb des Viewports
|
||||
|
||||
**Struktur:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ [User-Icon] top │ ← fixed, z-30
|
||||
│ │
|
||||
│ │
|
||||
│ LifeWave Canvas │ ← absolute, z-0, fullscreen
|
||||
│ + Glow-Punkte │
|
||||
│ │
|
||||
│ │
|
||||
│ [+] bot │ ← fixed, z-30
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 — Glassmorphism CSS-System
|
||||
|
||||
**Ziel:** Wiederverwendbare Glass-Styles als CSS-Klassen (Referenz: die beiden Glassmorphism-Bilder).
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- In `src/css/app.scss` definieren:
|
||||
- `.glass` — Basis-Glaseffekt (`backdrop-filter: blur(20px)`, halbtransparenter Hintergrund, subtiler Border)
|
||||
- `.glass--panel` — Variante für das Slide-Up-Panel (stärkerer Blur, ~40px)
|
||||
- `.glass--button` — Variante für den schwebenden +-Button
|
||||
- Light/Dark-Mode Varianten über CSS Custom Properties
|
||||
- Subtiler `box-shadow` für Tiefe
|
||||
- 1px Border mit `rgba(255,255,255,0.15)` für den "Glas-Rand"
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Header & Navigation
|
||||
|
||||
### 2.1 — Header-Leiste
|
||||
|
||||
**Ziel:** Minimalistischer Header mit Logo links und User-Icon rechts.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Direkt im `LifeWaveLayout.vue` als `<header>` Element (fixed, z-20)
|
||||
- **Links:** "ThatsMe" als Text-Logo (wird später durch echtes Logo ersetzt). Klick = Dark/Light Mode Toggle (temporär).
|
||||
- **Rechts:** Runder User-Button (40px), `person_outline` Icon, `.glass--button` Styling
|
||||
- Klick öffnet Off-Canvas-Drawer (rechte Seite)
|
||||
- Später: Eingeloggt = Avatar/Initialen mit Glow-Ring
|
||||
|
||||
### 2.2 — Floating Action Button "+" (Bottom-Center)
|
||||
|
||||
**Ziel:** Einzelner schwebender Button zum Erstellen eines neuen Events.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Komponente `src/components/AddEventButton.vue`
|
||||
- Runder Button (56px), positioniert `bottom: 32px; left: 50%; transform: translateX(-50%); position: fixed`
|
||||
- `.glass--button` Styling mit leichtem Glow-Effekt
|
||||
- Material Icon `add` (weiß/grau je nach Theme)
|
||||
- Klick-Animation: leichter Scale-Pulse
|
||||
- Klick öffnet das Event-Panel (Phase 4)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Timeline & Event-Dots
|
||||
|
||||
### 3.1 — FloatingLines als optionaler Hintergrund
|
||||
|
||||
**Ziel:** FloatingLines bleibt als optionales Feature erhalten, ist aber standardmäßig deaktiviert.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- FloatingLines im Layout eingebunden mit `v-if="showFloatingLines"` (default: `false`)
|
||||
- Zuschaltbar über Toggle im Off-Canvas-Drawer
|
||||
- Konfiguration: `enabledWaves: ['middle']`, `linesGradient: ['#E94FF5', '#2F4BA2']`, `lineCount: [12]`, `animationSpeed: 0.6`
|
||||
- Dient als Inspiration für die spätere Verbindungslinie (Phase 3.3)
|
||||
|
||||
### 3.2 — Scrollbare Timeline mit Glow-Dots
|
||||
|
||||
**Ziel:** Events als leuchtende Kreise auf einer horizontal scrollbaren Zeitachse darstellen — wie ein visuelles Tagebuch.
|
||||
Referenz: `Bildschirmfoto 2026-02-20 um 10.29.01.png`
|
||||
|
||||
**Konzept:**
|
||||
|
||||
- Der Viewport zeigt immer nur einen **Ausschnitt** der Timeline
|
||||
- Die Timeline-Breite berechnet sich aus der Zeitspanne aller Events
|
||||
- Horizontal scrollen (Touch-Swipe / Maus) navigiert durch die Zeit
|
||||
- Unten werden **Monat und Jahr** eingeblendet (aktuell sichtbarer Bereich hervorgehoben)
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Neue Komponente `src/components/TimelineView.vue` — der scrollbare Container
|
||||
- Timeline-Container: `overflow-x: auto; overflow-y: hidden; height: 100%`
|
||||
- Innerer Container: Breite berechnet aus Datumsbereich (z.B. 120px pro Monat)
|
||||
- **X-Achse (horizontal):** Datum-basierte Position — jedes Event wird auf seinen exakten Zeitpunkt positioniert
|
||||
- **Y-Achse (vertikal):** Emotionswert (-1 bis +1 → unten bis oben, Mitte = unsichtbare Nulllinie = neutral)
|
||||
- **Monats-Labels:** Am unteren Rand der Timeline, der aktuelle Monat groß/fett, Nachbarmonate kleiner
|
||||
- **Jahres-Label:** Unter den Monaten, wechselt beim Scrollen
|
||||
|
||||
**GlowDot-Komponente** (`src/components/GlowDot.vue`):
|
||||
|
||||
- Jeder Punkt ist ein `<div>` mit radialem CSS-Gradient als "Glow"
|
||||
- Größe: 20-28px Kern
|
||||
- Glow-Farbe durch Emotion ODER Custom-Farbe
|
||||
- Glow-Farblogik:
|
||||
- **Positiv** (> 0): `#FF6B35` (Orange) → `#FFD700` (Gold) → `#4CAF50` (Grün)
|
||||
- **Negativ** (< 0): `#2196F3` (Blau) → `#9C27B0` (Violett) → `#E91E63` (Pink)
|
||||
- **Custom:** User-Farbe überschreibt den Glow, Y-Position bleibt
|
||||
- Klick auf Punkt → öffnet Event-Detail (Phase 5)
|
||||
|
||||
### 3.4 — Timeline-Zoom
|
||||
|
||||
**Ziel:** Der User kann in die Timeline rein- und rauszoomen. Events und Abstände skalieren, Monats-/Jahres-Schriftgrößen bleiben konstant.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- `zoomLevel` (0.4–3.0, Default 1.0) multipliziert das `EVENT_SPACING`
|
||||
- GlowDots skalieren via CSS `transform: scale(var(--dot-scale))` über das `zoom`-Prop
|
||||
- **Desktop:** Ctrl+Mausrad oder Trackpad-Pinch (beides feuert `wheel` mit `ctrlKey`)
|
||||
- **Touch:** Zwei-Finger-Pinch-Geste
|
||||
- Scroll-Position bleibt stabil: Der Punkt unter dem Cursor/Pinch-Zentrum bleibt fixiert
|
||||
- Monats- und Jahres-Labels behalten ihre feste Schriftgröße
|
||||
|
||||
### 3.3 — Verbindungslinien zwischen Dots
|
||||
|
||||
**Ziel:** Eine farbverlaufende Linie verbindet die Glow-Punkte und bildet die "LifeWave". Visuell inspiriert durch die FloatingLines-Ästhetik.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- SVG-Overlay innerhalb des scrollbaren Timeline-Containers
|
||||
- Cubic Bezier Pfade (`<path>`) zwischen den Dots
|
||||
- Gradient entlang des Pfades: Start-Farbe = Glow des linken Dots, End-Farbe = Glow des rechten Dots
|
||||
- Strichbreite: 2-3px, mit `filter: blur(2px)` für weichen Glow
|
||||
- Organisches Feeling: Bezier-Kontrollpunkte leicht versetzt
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Neues Event anlegen (Slide-Up Panel)
|
||||
|
||||
### 4.1 — Event-Panel Komponente
|
||||
|
||||
**Ziel:** Glassmorphes Panel fährt von unten hoch. LifeWave bleibt sichtbar und wird durch das "Glas" diffus.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Neue Komponente `src/components/EventPanel.vue`
|
||||
- Positionierung: `position: fixed; bottom: 0; left: 0; right: 0; z-index: 20`
|
||||
- Höhe: ~65% des Viewports (variabel, Drag-Handle zum Resizen optional)
|
||||
- `.glass--panel` Styling (starker Backdrop-Blur ~40px)
|
||||
- Abgerundete obere Ecken (`border-radius: 24px 24px 0 0`)
|
||||
- Slide-Up Animation: `transform: translateY(100%)` → `translateY(0)` mit `transition` oder Vue `<Transition>`
|
||||
- Kleiner Drag-Handle-Balken oben (visueller Indikator, 40px breit, 4px hoch, zentriert)
|
||||
- **Wichtig:** Hintergrund-LifeWave wird NICHT ausgeblendet — der Glaseffekt erzeugt die Unschärfe
|
||||
|
||||
### 4.2 — Event-Formular (Felder im Panel)
|
||||
|
||||
**Ziel:** Minimalistisches Formular mit Live-Feedback.
|
||||
|
||||
**Felder:**
|
||||
|
||||
1. **Titel** — Textfeld, Placeholder "Was ist passiert?"
|
||||
2. **Datum** — Date-Picker, Default = heute
|
||||
3. **Emotions-Regler** — Custom Slider von -1.0 (negativ) bis +1.0 (positiv)
|
||||
- Visuelles Feedback: Slider-Track ändert Farbe (Blau/Kühl ← → Warm/Grün)
|
||||
- Thumb-Farbe passt sich an
|
||||
4. **Farbe anpassen** (optional) — Toggle + Color-Picker
|
||||
- Default: aus (Emotion bestimmt Farbe)
|
||||
- Es gibt vorgefertigte Farbverläufe für den emotions Leiter hier zum Beispiel Farbkombinationen
|
||||
Verläufe
|
||||
Nagativ -> neutral -> Positiv
|
||||
#FD1D1D -> #FCB045 -> #833AB4
|
||||
#ED8153 -> #ED8153 -> #217B9E
|
||||
#00D4FF -> #164173 -> #440559
|
||||
#FDBB2D -> #96BE74 -> #22C1C3
|
||||
#FC466B -> #9A52B6 -> #3F5EFB
|
||||
#EEAECA -> #C2B4D9 -> #94BBE9
|
||||
|
||||
5. **Notiz** — Textarea, optional, 3 Zeilen
|
||||
6. **Speichern-Button** — Glass-Style, auffällig
|
||||
|
||||
### 4.3 — Live-Feedback (Der Wow-Moment)
|
||||
|
||||
**Ziel:** Während der User das Formular ausfüllt, erscheint in Echtzeit ein neuer Glow-Punkt im Hintergrund.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Sobald das Panel geöffnet wird: ein "Ghost-Dot" (halbtransparent, pulsierend) erscheint auf der Wave
|
||||
- **Emotions-Regler bewegen:** Ghost-Dot wandert auf der Y-Achse nach oben/unten, Glow-Farbe ändert sich live
|
||||
- **Custom-Farbe wählen:** Glow-Farbe ändert sich sofort
|
||||
- **Speichern:** Ghost-Dot wird zum festen Dot (Opacity 1.0, Puls-Animation → statisch), Verbindungslinie animiert sich zum neuen Punkt
|
||||
- Ghost-Dot hat stärkere Puls-Animation als reguläre Dots
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Event bearbeiten & Detail-Ansicht
|
||||
|
||||
### 5.1 — Event-Detail (Tap auf Glow-Dot)
|
||||
|
||||
**Ziel:** Tap auf einen Glow-Dot öffnet eine kompakte Detail-Ansicht.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Wiederverwendung des `EventPanel.vue` im "Edit-Modus"
|
||||
- Panel fährt hoch, vorbelegt mit den Event-Daten
|
||||
- Gleiche Felder wie beim Anlegen
|
||||
- Zusätzlich: "Löschen"-Button (dezent, unten im Panel)
|
||||
- Live-Feedback: Änderungen am Emotions-Regler / Farbe ändern den existierenden Dot in Echtzeit
|
||||
|
||||
### 5.2 — Dot-Selection-State
|
||||
|
||||
**Ziel:** Visuelles Feedback welcher Dot ausgewählt ist.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Ausgewählter Dot bekommt:
|
||||
- Vergrößerter Glow (Scale 1.3 mit Transition)
|
||||
- Pulsierender Ring-Effekt (CSS Animation)
|
||||
- Alle anderen Dots werden leicht gedimmt (Opacity 0.5)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Smart-Panel & Einstellungen
|
||||
|
||||
### 6.1 — LifeWave-Einstellungen (User-Personalisierung)
|
||||
|
||||
**Ziel:** Der User kann seine Wave anpassen — das macht die App persönlich.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Im User-Menü (Off-Canvas rechts) unter "Life Wave":
|
||||
- **Wellen-Farben:** Gradient-Picker (2-4 Farben auswählen)
|
||||
- **Wellen-Intensität:** Slider (lineCount: 4-20)
|
||||
- **Wellen-Geschwindigkeit:** Slider (animationSpeed: 0.2-2.0)
|
||||
- **Wellen-Stil:** Auswahl welche Waves aktiv sind (top/middle/bottom)
|
||||
- Änderungen werden live auf die FloatingLines-Props angewendet
|
||||
- Einstellungen im Pinia Store + localStorage persistiert
|
||||
|
||||
### 6.2 — Dark/Light Mode Toggle
|
||||
|
||||
**Ziel:** Nahtloser Wechsel zwischen Hell- und Dunkel-Modus.
|
||||
|
||||
**Umsetzung:**
|
||||
|
||||
- Toggle im User-Menü oder als kleines Icon neben dem User-Button
|
||||
- Quasar Dark-Mode Plugin aktivieren (`$q.dark.toggle()`)
|
||||
- CSS Custom Properties wechseln alle Farben
|
||||
- FloatingLines `mixBlendMode` wechselt automatisch
|
||||
- Hintergrundfarbe animiert den Übergang (300ms Transition)
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell (Pinia Store)
|
||||
|
||||
### `src/stores/events.js`
|
||||
|
||||
```js
|
||||
{
|
||||
events: [
|
||||
{
|
||||
id: 'uuid',
|
||||
title: 'Hochzeit von Lisa',
|
||||
date: '2024-06-15',
|
||||
emotion: 0.8, // -1.0 bis +1.0
|
||||
customColor: null, // null = auto, '#FF00FF' = custom
|
||||
note: 'Wunderschöner Tag im Garten...',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `src/stores/settings.js`
|
||||
|
||||
```js
|
||||
{
|
||||
theme: 'light', // 'light' | 'dark'
|
||||
wave: {
|
||||
gradient: ['#E94FF5', '#2F4BA2'],
|
||||
lineCount: 12,
|
||||
speed: 0.6,
|
||||
enabledWaves: ['middle']
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Umsetzungsreihenfolge (Arbeitspakete)
|
||||
|
||||
| # | Paket | Abhängigkeit | Geschätzt |
|
||||
| --- | --------------------------------------- | ------------ | ---------- |
|
||||
| 1 | Phase 1.1 — LifeWaveLayout + Routing | — | Grundlage |
|
||||
| 2 | Phase 1.2 — Glassmorphism CSS | — | Grundlage |
|
||||
| 3 | Phase 3.1 — FloatingLines Fullscreen | #1 | Wow-Basis |
|
||||
| 4 | Phase 2.1 — UserMenuButton | #1, #2 | Navigation |
|
||||
| 5 | Phase 2.2 — AddEventButton | #1, #2 | Navigation |
|
||||
| 6 | Phase 4.1 — EventPanel (Slide-Up, leer) | #2 | Panel |
|
||||
| 7 | Datenmodell — Pinia Stores | — | Daten |
|
||||
| 8 | Phase 3.2 — GlowDots | #3, #7 | Events |
|
||||
| 9 | Phase 3.3 — LifeWavePath (Verbindungen) | #8 | Wave |
|
||||
| 10 | Phase 4.2 — Event-Formular | #6, #7 | Eingabe |
|
||||
| 11 | Phase 4.3 — Live-Feedback (Ghost-Dot) | #8, #10 | Wow! |
|
||||
| 12 | Phase 5 — Event bearbeiten & Detail | #10, #8 | Edit |
|
||||
| 13 | Phase 6.1 — LifeWave-Einstellungen | #3, #7 | Settings |
|
||||
| 14 | Phase 6.2 — Dark/Light Mode | #1 | Theme |
|
||||
|
||||
---
|
||||
|
||||
## Demo-Daten für den Prototyp
|
||||
|
||||
8 Events vorbelegt — 4 mit Bildern, 4 ohne. Bilder liegen in `public/demo/`.
|
||||
|
||||
```js
|
||||
;[
|
||||
{ title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, image: null },
|
||||
{
|
||||
title: 'Abiball',
|
||||
date: '2004-06-25',
|
||||
emotion: 0.85,
|
||||
image: 'demo/photo-1530103862676-de8c9debad1d.jpeg',
|
||||
},
|
||||
{ title: 'Trennung', date: '2010-03-15', emotion: -0.7, image: null },
|
||||
{
|
||||
title: 'Bergwanderung',
|
||||
date: '2014-08-12',
|
||||
emotion: 0.75,
|
||||
image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg',
|
||||
},
|
||||
{ title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, image: null },
|
||||
{
|
||||
title: 'Hochzeit',
|
||||
date: '2018-07-20',
|
||||
emotion: 0.95,
|
||||
customColor: '#E94FF5',
|
||||
image: 'demo/photo-1506905925346-21bda4d32df4.jpeg',
|
||||
},
|
||||
{ title: 'Umzug', date: '2021-04-01', emotion: -0.3, image: null },
|
||||
{
|
||||
title: 'Neuer Job',
|
||||
date: '2023-01-10',
|
||||
emotion: 0.5,
|
||||
image: 'demo/photo-1530103862676-de8c9debad1d.jpeg',
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technische Hinweise
|
||||
|
||||
- **Three.js** ist bereits als Dependency vorhanden (über FloatingLines.vue)
|
||||
- **Keine neuen Dependencies** nötig für Phase 1-5 (alles mit Quasar + CSS + SVG + Three.js machbar)
|
||||
- **Performance:** GlowDots und LifeWavePath als separate Schicht ÜBER dem WebGL-Canvas (DOM, nicht WebGL) — einfacher zu implementieren, gute Performance bei <50 Events
|
||||
- **Mobile-First:** Alle Maße in `dvh`/`dvw`, Touch-Events für Slider, Panel-Swipe
|
||||
387
frontend/dev/UMSETZUNG-FLOATING-LINES.md
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
# FloatingLines Migration & Event-Integration
|
||||
|
||||
**Stand:** 20. Februar 2026
|
||||
**Bereich:** Frontend (Quasar/Vue.js 3)
|
||||
|
||||
---
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
Die alte LifeWave-Visualisierung (zwei Versionen: Spline + Glow) wurde komplett durch eine einzige **FloatingLines**-Implementierung ersetzt. Diese basiert auf einem WebGL-Fragment-Shader (Three.js), der animierte Bezier-Linien zwischen Event-Punkten zeichnet, inklusive farbiger Glow-Kreise pro Event.
|
||||
|
||||
### Was wurde gemacht
|
||||
|
||||
1. **Shader aus `dev/floating-lines.js` migriert** → `FloatingLines.vue`
|
||||
2. **Settings aus `dev/init-fl.html` migriert** → `LifeWaveSettings.vue` + `settings.js` Store
|
||||
3. **Event-Punkte mit Shader synchronisiert** — Shader-Kreise sitzen exakt auf den GlowDots
|
||||
4. **Per-Event-Farben** — Jeder Shader-Kreis und jedes Liniensegment nutzt die Emotion-Farbe des Events
|
||||
5. **GlowDot vereinfacht** — Nur noch weißer Kreis + Bild als Klick-Target, Glow kommt vom Shader
|
||||
6. **Kreisgröße synchronisiert** — Settings-Slider steuert Shader-Kreis UND DOM-Dot
|
||||
7. **Zoom entkoppelt** — Zoom ändert Abstände, nicht Kreisgrößen
|
||||
|
||||
### Gelöschte Dateien
|
||||
|
||||
- `LifeWavePath.vue` — Alte SVG-Pfad-Visualisierung
|
||||
- `LifeWaveSpline.vue` — Alte Spline-Kurven-Variante
|
||||
- `LifeWaveGlow.vue` — Alte Glow-Effekt-Variante
|
||||
- `WaveSettings.vue` — Alte Settings (ersetzt durch `LifeWaveSettings.vue`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LifeWaveLayout.vue │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ FloatingLines.vue (WebGL Fullscreen) │ │
|
||||
│ │ - Fragment Shader (GLSL) │ │
|
||||
│ │ - Bezier-Linien zwischen Events │ │
|
||||
│ │ - Glow-Kreise pro Event (pointColor[]) │ │
|
||||
│ │ - Animierte Wellen │ │
|
||||
│ │ - Background Gradient + optionales Bild │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ TimelineView.vue (scrollbar, z-index: 5) │ │
|
||||
│ │ - Horizontales Scroll-Container │ │
|
||||
│ │ - GlowDot pro Event (Klick-Target) │ │
|
||||
│ │ - Monat/Jahr-Labels │ │
|
||||
│ │ - Pinch-to-Zoom │ │
|
||||
│ │ - Emittiert @view-update an Layout │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Header | AddEventButton | EventPanel | LifeWaveSettings│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenfluss
|
||||
|
||||
### 3.1 Event-Positionen → Shader
|
||||
|
||||
```
|
||||
TimelineView LifeWaveLayout FloatingLines
|
||||
───────────── ────────────── ──────────────
|
||||
displayEvents ──@viewUpdate──► onViewUpdate()
|
||||
(emotion, x, color) │
|
||||
├─ shaderNumPoints (computed)
|
||||
├─ shaderPointX[] (computed) ──► pointX[8] uniform
|
||||
├─ shaderPointY[] (computed) ──► pointY[8] uniform
|
||||
└─ shaderPointColors[] (comp.) ──► pointColor[8] uniform
|
||||
```
|
||||
|
||||
**Koordinaten-Konvertierung (Screen → Shader UV):**
|
||||
|
||||
```js
|
||||
// Layout: screenToUV(sx, sy)
|
||||
// sx, sy = CSS-Pixel vom oberen linken Viewport-Rand
|
||||
function screenToUV(sx, sy) {
|
||||
const w = layoutWidth // = 100dvh Breite
|
||||
const h = layoutHeight // = 100dvh Höhe
|
||||
return {
|
||||
x: (2 * sx - w) / h,
|
||||
y: (2 * sy - h) / h
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glsl
|
||||
// Shader: gleiche Formel
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0; // Y-Flip (CSS: top→bottom, GL: bottom→top)
|
||||
```
|
||||
|
||||
**GlowDot Y-Position:**
|
||||
```
|
||||
yPercent = 50 - emotion * 35
|
||||
emotion +1.0 → top (15%)
|
||||
emotion 0.0 → mitte (50%)
|
||||
emotion -1.0 → unten (85%)
|
||||
```
|
||||
|
||||
**Screen Y für Shader:**
|
||||
```
|
||||
TIMELINE_TOP = 60px (CSS: .timeline { top: 60px })
|
||||
screenY = TIMELINE_TOP + (yPercent / 100) * containerHeight
|
||||
```
|
||||
|
||||
### 3.2 Emotion-Slider → Live-Update
|
||||
|
||||
```
|
||||
EventPanel Events Store TimelineView Shader
|
||||
────────── ──────────── ──────────── ──────
|
||||
v-model="ghostEmotion" ──► ghostEmotion (ref)
|
||||
│
|
||||
├─ watch → persistToEvent()
|
||||
│ (updates events[])
|
||||
│
|
||||
└─ sortedEvents (computed) ──► displayEvents
|
||||
│
|
||||
└─ watch → emitViewState()
|
||||
│
|
||||
Layout: shaderPointY[]
|
||||
Layout: shaderPointColors[]
|
||||
│
|
||||
FloatingLines: watch → uniform update
|
||||
```
|
||||
|
||||
### 3.3 Event-Farben
|
||||
|
||||
Jeder Event hat eine Glow-Farbe basierend auf:
|
||||
1. `event.customColor` (falls gesetzt, hat Priorität)
|
||||
2. `emotionToColor(emotion, gradientPreset)` — interpoliert zwischen 3 Farben
|
||||
|
||||
```
|
||||
events.js: getGlowColor(event)
|
||||
→ customColor || emotionToColor(emotion, gradientPreset)
|
||||
|
||||
10 Gradient-Presets: Standard, Sunset, Earth, Ocean, Spring,
|
||||
Neon, Pastel, Aurora, Forest, Berry
|
||||
```
|
||||
|
||||
Die Farbe fließt als `pointColor[8]` Uniform in den Shader:
|
||||
- **Kreise:** `vec3 circCol = pointColor[p]`
|
||||
- **Liniensegmente:** `vec3 lineCol = mix(pointColor[s], pointColor[s+1], t_seg)`
|
||||
|
||||
---
|
||||
|
||||
## 4. Komponenten-Referenz
|
||||
|
||||
### 4.1 FloatingLines.vue
|
||||
|
||||
**Zweck:** Fullscreen WebGL-Hintergrund mit animierten Bezier-Linien und Glow-Kreisen.
|
||||
|
||||
**Technologie:** Three.js mit custom Fragment Shader (GLSL).
|
||||
|
||||
**Props:**
|
||||
|
||||
| Prop | Typ | Default | Beschreibung |
|
||||
|------|-----|---------|-------------|
|
||||
| `numPoints` | Number | 0 | Anzahl aktiver Punkte (max 8) |
|
||||
| `pointXValues` | Array | [] | X-UV-Koordinaten der Punkte |
|
||||
| `pointYValues` | Array | [] | Y-UV-Koordinaten der Punkte |
|
||||
| `pointColors` | Array | [] | Hex-Farben pro Punkt (z.B. '#ff0000') |
|
||||
| `lineCount` | Array/Number | [10] | Anzahl Wellenlinien |
|
||||
| `animationSpeed` | Number | 1 | Geschwindigkeit der Wellenanimation |
|
||||
| `lineSpread` | Number | 0.05 | Wellenamplitude |
|
||||
| `fanSpread` | Number | 0.05 | Fächerbreite der Linien |
|
||||
| `lineSharpness` | Number | 8.0 | Feinheit/Schärfe der Linien |
|
||||
| `waveFrequency` | Number | 7.0 | Welligkeit |
|
||||
| `bezierCurvature` | Number | 0.2 | Kurvenstärke der Bezier-Verbindungen |
|
||||
| `circleRadiusPx` | Number | 75 | Kreisradius in Pixeln |
|
||||
| `circleGlowSize` | Number | 18 | Glow-Ausdehnung um den Kreis |
|
||||
| `circleGlowStrength` | Number | 1.5 | Glow-Intensität |
|
||||
| `linesGradient` | Array | [...] | Hex-Farbwerte für Linien-Gradient |
|
||||
| `bgColorCenter` | String | '#0a0514' | Hintergrundfarbe Mitte |
|
||||
| `bgColorEdge` | String | '#000000' | Hintergrundfarbe Rand |
|
||||
| `backgroundImage` | String | '' | URL für Hintergrundbild |
|
||||
| `mixBlendMode` | String | 'screen' | CSS Blend-Mode des Canvas |
|
||||
|
||||
**Shader-Architektur:**
|
||||
- `drawCircle()` — Zeichnet weißen Kern + farbigen Glow + Fog
|
||||
- `waveFocal()` — Berechnet Wellenlinien entlang Bezier-Segmenten
|
||||
- `bezierClosestT()` — Findet nächsten Punkt auf quadratischer Bezier-Kurve
|
||||
- `mainImage()` — Compositing: Background + Segmente + Kreise
|
||||
|
||||
### 4.2 GlowDot.vue
|
||||
|
||||
**Zweck:** Klickbarer DOM-Overlay pro Event (weißer Kreis + optionales Bild).
|
||||
|
||||
**Größe:** Dynamisch aus `settingsStore.floatingLines.circleRadius`:
|
||||
```js
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const dotSize = 2 * circleRadius / dpr // Matches shader circle
|
||||
```
|
||||
|
||||
**Kein Zoom-Scaling** — Größe ist konstant, unabhängig vom Zoom-Level.
|
||||
|
||||
**Props:** `event`, `x`, `isGhost`, `selected`
|
||||
|
||||
### 4.3 TimelineView.vue
|
||||
|
||||
**Zweck:** Horizontal scrollbarer Container mit GlowDots und Labels.
|
||||
|
||||
**CSS-Position:** `top: 60px; bottom: 70px` (unterhalb Header, oberhalb AddButton)
|
||||
|
||||
**Features:**
|
||||
- Pinch-to-Zoom (Touch + Ctrl+Wheel)
|
||||
- Zoom-Range: 0.4x – 3.0x
|
||||
- Scroll-to-center beim Mount (letztes Event)
|
||||
- Ghost-Event-Insertion bei Panel-Open (Create-Mode)
|
||||
|
||||
**Emits:**
|
||||
- `@dotSelect(eventId)` — Event angeklickt
|
||||
- `@viewUpdate({ scrollLeft, viewportWidth, containerHeight, events[] })` — Bei jedem Scroll/Zoom/Resize/Event-Change
|
||||
|
||||
### 4.4 LifeWaveLayout.vue
|
||||
|
||||
**Zweck:** Haupt-Layout, orchestriert alle Komponenten.
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
- Empfängt `@view-update` von TimelineView
|
||||
- Konvertiert Screen-Pixel → Shader-UV-Koordinaten
|
||||
- Berechnet `shaderNumPoints`, `shaderPointX[]`, `shaderPointY[]`, `shaderPointColors[]`
|
||||
- Reicht Settings-Store-Werte an FloatingLines weiter
|
||||
- Parsed Gradient-Stops aus dem Textarea-String
|
||||
|
||||
**Wichtige Konstante:** `TIMELINE_TOP = 60` (muss mit `.timeline { top: 60px }` übereinstimmen)
|
||||
|
||||
### 4.5 LifeWaveSettings.vue
|
||||
|
||||
**Zweck:** Einstellungs-Panel (Slide-Up, 75dvh).
|
||||
|
||||
**Sektionen:**
|
||||
1. **Linien** — Speed, Anzahl, Wellen-Amp, Fächerbreite, Feinheit, Welligkeit, Kurve, Kreis, Glow Größe, Glow Stärke
|
||||
2. **Hintergrundbild** — 10 vordefinierte Bilder (`/images/bg-image-1.jpg` bis `10.jpg`)
|
||||
3. **Hintergrundfarbe** — BG Mitte + BG Rand (Color Picker)
|
||||
4. **Farbverlauf** — Textarea mit Hex-Werten (eine pro Zeile)
|
||||
5. **Extras** — Dark/Light-Mode Toggle
|
||||
6. **Reset** — Setzt alle Werte auf Defaults zurück
|
||||
|
||||
### 4.6 EventPanel.vue
|
||||
|
||||
**Zweck:** Event-Erstellung und -Bearbeitung (Slide-Up, 75dvh).
|
||||
|
||||
**Features:**
|
||||
- Key Image Upload (Platzhalter)
|
||||
- Titel-Input (inline, groß)
|
||||
- Datum-Picker (QDate mit deutscher Locale)
|
||||
- Emotion-Slider (-1 bis +1) mit Gradient-Track
|
||||
- 10 Gradient-Presets + "Standard"-Option
|
||||
- Beschreibungs-Textarea
|
||||
- Weitere Medien (Platzhalter)
|
||||
- Event löschen (nur Edit-Mode)
|
||||
- Auto-Save: Änderungen werden sofort auf das Event persistiert
|
||||
|
||||
---
|
||||
|
||||
## 5. Stores
|
||||
|
||||
### 5.1 events.js
|
||||
|
||||
```js
|
||||
// State
|
||||
events // Array aller Events
|
||||
selectedEventId // Aktuell ausgewählter Event (oder null)
|
||||
panelOpen // Ob EventPanel offen ist
|
||||
editingEventId // ID des Events im Edit-Mode (null = Create)
|
||||
ghost* // Temporäre Felder für Live-Preview (ghostEmotion, ghostTitle, ...)
|
||||
|
||||
// Computed
|
||||
ghostEvent // Computed Event-Objekt aus ghost-Feldern
|
||||
sortedEvents // Nach Datum sortierte Events
|
||||
|
||||
// Methods
|
||||
selectEvent(id), openPanel(eventId?), closePanel(), deleteEvent(id)
|
||||
getGlowColor(event) // → Hex-Farbe basierend auf Emotion + Preset
|
||||
```
|
||||
|
||||
**Demo-Daten:** 8 Events (1995–2023) mit verschiedenen Emotionen, Presets und Bildern.
|
||||
|
||||
### 5.2 settings.js
|
||||
|
||||
```js
|
||||
// State
|
||||
theme // 'light' | 'dark'
|
||||
floatingLines // Objekt mit allen Shader-Parametern
|
||||
|
||||
// Methods
|
||||
toggleTheme(), updateFloatingLines(changes), resetFloatingLines()
|
||||
|
||||
// Persistence
|
||||
localStorage.setItem('thatsme-settings', JSON.stringify({...}))
|
||||
```
|
||||
|
||||
**Defaults:** Siehe `FLOATING_LINES_DEFAULTS` in `settings.js`.
|
||||
|
||||
---
|
||||
|
||||
## 6. CSS-Architektur
|
||||
|
||||
### 6.1 Globale Styles (`app.scss`)
|
||||
|
||||
- `.glass--button` — Glasmorphismus für Buttons (blur + transparenter Hintergrund)
|
||||
- `.glass--panel` — Glasmorphismus für Slide-Up-Panels
|
||||
- Light: `background: rgba(255,255,255,0.7); color: #1a1a1a`
|
||||
- Dark: `background: rgba(30,30,30,0.7); color: #f5f5f5`
|
||||
|
||||
### 6.2 Quasar Theme (`quasar.variables.scss`)
|
||||
|
||||
```scss
|
||||
$primary : #d946ef; // Fuchsia — Slider, Toggles, aktive States
|
||||
$secondary : #a855f7; // Purple
|
||||
$accent : #ec4899; // Pink
|
||||
```
|
||||
|
||||
### 6.3 Wichtige CSS-Hinweise
|
||||
|
||||
**Timeline-Positionierung:**
|
||||
```css
|
||||
/* TimelineView.vue — eigene Positionierung */
|
||||
.timeline { position: absolute; top: 60px; bottom: 70px; }
|
||||
|
||||
/* LifeWaveLayout.vue — NUR z-index, KEIN inset: 0! */
|
||||
/* inset: 0 würde top/bottom der Timeline überschreiben (CSS Cascade) */
|
||||
.lifewave-layout__timeline { z-index: 5; }
|
||||
```
|
||||
|
||||
**GlowDot — kein Zoom-Scaling:**
|
||||
```css
|
||||
.glow-dot { transform: translate(-50%, -50%); }
|
||||
/* Breite/Höhe kommt dynamisch aus dem Settings-Store */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Bekannte Einschränkungen
|
||||
|
||||
1. **Max 8 Events im Shader** — `pointX[8]`, `pointY[8]`, `pointColor[8]` sind fest auf 8 begrenzt. Bei mehr als 8 Events werden nur die ersten 8 als Shader-Punkte dargestellt.
|
||||
2. **Bilder nur als Demo** — Key-Image-Upload und Medien-Upload sind Platzhalter (TODO).
|
||||
3. **Kein Backend-Sync** — Alle Daten liegen nur lokal (Demo-Events + localStorage für Settings).
|
||||
4. **DPR-Abhängigkeit** — Die GlowDot-Größe wird einmalig beim Mount aus `window.devicePixelRatio` berechnet. Bei Wechsel zwischen Displays (z.B. Retina → Nicht-Retina) stimmt die Größe nicht mehr exakt.
|
||||
5. **Hintergrundbilder** — Müssen unter `/images/bg-image-{1-10}.jpg` auf dem Webspace liegen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Entwicklung fortsetzen
|
||||
|
||||
### Dev-Server starten
|
||||
|
||||
```bash
|
||||
# Im Docker-Container quasar.app:
|
||||
npm run dev
|
||||
# → http://app.thats-me.test:9000
|
||||
```
|
||||
|
||||
### Produktions-Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# → Output: frontend/dist/spa/
|
||||
# Statisches SPA, einfach hochladen
|
||||
```
|
||||
|
||||
### Dateien für die Weiterentwicklung
|
||||
|
||||
| Was | Wo |
|
||||
|-----|-----|
|
||||
| Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67–366) |
|
||||
| UV-Konvertierung | `LifeWaveLayout.vue` → `screenToUV()` |
|
||||
| Event-Farben | `events.js` → `emotionToColor()`, `getGlowColor()` |
|
||||
| Settings-Defaults | `settings.js` → `FLOATING_LINES_DEFAULTS` |
|
||||
| Slider-Ranges | `LifeWaveSettings.vue` (`:min`, `:max`, `:step` auf jedem `q-slider`) |
|
||||
| Quasar-Theme | `quasar.variables.scss` |
|
||||
| Glass-Styles | `app.scss` → `.glass--panel`, `.glass--button` |
|
||||
| Dev-Referenz | `dev/init-fl.html`, `dev/floating-lines.js` (Original-Prototyp) |
|
||||
|
||||
### Nächste Schritte (offen)
|
||||
|
||||
- [ ] Key-Image-Upload implementieren (Camera/File-Picker → IndexedDB/S3)
|
||||
- [ ] Medien-Gallery pro Event
|
||||
- [ ] Backend-Sync über Laravel REST API
|
||||
- [ ] Mehr als 8 Events im Shader unterstützen (dynamisches Chunking oder LOD)
|
||||
- [ ] Touch-Gesten: Long-Press auf GlowDot für Kontextmenü
|
||||
- [ ] Onboarding / Leer-Zustand wenn keine Events vorhanden
|
||||
457
frontend/dev/floating-lines copy 2.js
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
import {
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
WebGLRenderer,
|
||||
PlaneGeometry,
|
||||
Mesh,
|
||||
ShaderMaterial,
|
||||
Vector3,
|
||||
Vector2,
|
||||
Clock,
|
||||
} from 'three'
|
||||
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float animationSpeed;
|
||||
|
||||
uniform bool enableTop;
|
||||
uniform bool enableMiddle;
|
||||
uniform bool enableBottom;
|
||||
|
||||
uniform int topLineCount;
|
||||
uniform int middleLineCount;
|
||||
uniform int bottomLineCount;
|
||||
|
||||
uniform float topLineDistance;
|
||||
uniform float middleLineDistance;
|
||||
uniform float bottomLineDistance;
|
||||
|
||||
uniform vec3 topWavePosition;
|
||||
uniform vec3 middleWavePosition;
|
||||
uniform vec3 bottomWavePosition;
|
||||
|
||||
uniform vec2 iMouse;
|
||||
uniform bool interactive;
|
||||
uniform float bendRadius;
|
||||
uniform float bendStrength;
|
||||
uniform float bendInfluence;
|
||||
|
||||
uniform bool parallax;
|
||||
uniform float parallaxStrength;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
|
||||
const vec3 BLACK = vec3(0.0);
|
||||
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
||||
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
||||
|
||||
mat2 rotate(float r) {
|
||||
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
||||
}
|
||||
|
||||
vec3 background_color(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
||||
float m = uv.y - y;
|
||||
|
||||
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
||||
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
||||
return col * 0.5;
|
||||
}
|
||||
|
||||
vec3 getLineColor(float t, vec3 baseColor) {
|
||||
if (lineGradientCount <= 0) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
vec3 gradientColor;
|
||||
|
||||
if (lineGradientCount == 1) {
|
||||
gradientColor = lineGradient[0];
|
||||
} else {
|
||||
float clampedT = clamp(t, 0.0, 0.9999);
|
||||
float scaled = clampedT * float(lineGradientCount - 1);
|
||||
int idx = int(floor(scaled));
|
||||
float f = fract(scaled);
|
||||
int idx2 = min(idx + 1, lineGradientCount - 1);
|
||||
|
||||
vec3 c1 = lineGradient[idx];
|
||||
vec3 c2 = lineGradient[idx2];
|
||||
|
||||
gradientColor = mix(c1, c2, f);
|
||||
}
|
||||
|
||||
return gradientColor * 0.5;
|
||||
}
|
||||
|
||||
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
|
||||
float time = iTime * animationSpeed;
|
||||
|
||||
float x_offset = offset;
|
||||
float x_movement = time * 0.1;
|
||||
float amp = sin(offset + time * 0.2) * 0.3;
|
||||
float y = sin(uv.x + x_offset + x_movement) * amp;
|
||||
|
||||
if (shouldBend) {
|
||||
vec2 d = screenUv - mouseUv;
|
||||
float influence = exp(-dot(d, d) * bendRadius);
|
||||
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
|
||||
y += bendOffset;
|
||||
}
|
||||
|
||||
float m = uv.y - y;
|
||||
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0;
|
||||
|
||||
if (parallax) {
|
||||
baseUv += parallaxOffset;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec3 b = lineGradientCount > 0 ? vec3(0.0) : background_color(baseUv);
|
||||
|
||||
vec2 mouseUv = vec2(0.0);
|
||||
if (interactive) {
|
||||
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
||||
mouseUv.y *= -1.0;
|
||||
}
|
||||
|
||||
if (enableBottom) {
|
||||
for (int i = 0; i < bottomLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(bottomLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
|
||||
1.5 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableMiddle) {
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(middleLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = middleWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(middleLineDistance * fi + middleWavePosition.x, middleWavePosition.y),
|
||||
2.0 + 0.15 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableTop) {
|
||||
for (int i = 0; i < topLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(topLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
ruv.x *= -1.0;
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
|
||||
1.0 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
fragColor = vec4(col, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = vec4(0.0);
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_GRADIENT_STOPS = 8
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let value = hex.trim()
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
value = value.slice(1)
|
||||
}
|
||||
|
||||
let r = 255
|
||||
let g = 255
|
||||
let b = 255
|
||||
|
||||
if (value.length === 3) {
|
||||
r = parseInt(value[0] + value[0], 16)
|
||||
g = parseInt(value[1] + value[1], 16)
|
||||
b = parseInt(value[2] + value[2], 16)
|
||||
} else if (value.length === 6) {
|
||||
r = parseInt(value.slice(0, 2), 16)
|
||||
g = parseInt(value.slice(2, 4), 16)
|
||||
b = parseInt(value.slice(4, 6), 16)
|
||||
}
|
||||
|
||||
return new Vector3(r / 255, g / 255, b / 255)
|
||||
}
|
||||
|
||||
export default class FloatingLines {
|
||||
constructor(
|
||||
container,
|
||||
{
|
||||
linesGradient,
|
||||
enabledWaves = ['top', 'middle', 'bottom'],
|
||||
lineCount = [6],
|
||||
lineDistance = [5],
|
||||
topWavePosition,
|
||||
middleWavePosition,
|
||||
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
||||
animationSpeed = 1,
|
||||
interactive = true,
|
||||
bendRadius = 5.0,
|
||||
bendStrength = -0.5,
|
||||
mouseDamping = 0.05,
|
||||
parallax = true,
|
||||
parallaxStrength = 0.2,
|
||||
mixBlendMode = 'screen',
|
||||
} = {},
|
||||
) {
|
||||
this.container = container
|
||||
this.interactive = interactive
|
||||
this.parallax = parallax
|
||||
this.mouseDamping = mouseDamping
|
||||
this.parallaxStrength = parallaxStrength
|
||||
|
||||
this.targetMouse = new Vector2(-1000, -1000)
|
||||
this.currentMouse = new Vector2(-1000, -1000)
|
||||
this.targetInfluence = 0
|
||||
this.currentInfluence = 0
|
||||
this.targetParallax = new Vector2(0, 0)
|
||||
this.currentParallax = new Vector2(0, 0)
|
||||
|
||||
const getLineCount = (waveType) => {
|
||||
if (typeof lineCount === 'number') return lineCount
|
||||
if (!enabledWaves.includes(waveType)) return 0
|
||||
const index = enabledWaves.indexOf(waveType)
|
||||
return lineCount[index] ?? 6
|
||||
}
|
||||
|
||||
const getLineDistance = (waveType) => {
|
||||
if (typeof lineDistance === 'number') return lineDistance
|
||||
if (!enabledWaves.includes(waveType)) return 0.1
|
||||
const index = enabledWaves.indexOf(waveType)
|
||||
return lineDistance[index] ?? 0.1
|
||||
}
|
||||
|
||||
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0
|
||||
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0
|
||||
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0
|
||||
|
||||
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
|
||||
const middleLineDistance = enabledWaves.includes('middle')
|
||||
? getLineDistance('middle') * 0.01
|
||||
: 0.01
|
||||
const bottomLineDistance = enabledWaves.includes('bottom')
|
||||
? getLineDistance('bottom') * 0.01
|
||||
: 0.01
|
||||
|
||||
this.scene = new Scene()
|
||||
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
this.camera.position.z = 1
|
||||
|
||||
this.renderer = new WebGLRenderer({ antialias: true, alpha: false })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.domElement.style.width = '100%'
|
||||
this.renderer.domElement.style.height = '100%'
|
||||
this.renderer.domElement.style.mixBlendMode = mixBlendMode
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
this.uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: animationSpeed },
|
||||
|
||||
enableTop: { value: enabledWaves.includes('top') },
|
||||
enableMiddle: { value: enabledWaves.includes('middle') },
|
||||
enableBottom: { value: enabledWaves.includes('bottom') },
|
||||
|
||||
topLineCount: { value: topLineCount },
|
||||
middleLineCount: { value: middleLineCount },
|
||||
bottomLineCount: { value: bottomLineCount },
|
||||
|
||||
topLineDistance: { value: topLineDistance },
|
||||
middleLineDistance: { value: middleLineDistance },
|
||||
bottomLineDistance: { value: bottomLineDistance },
|
||||
|
||||
topWavePosition: {
|
||||
value: new Vector3(
|
||||
topWavePosition?.x ?? 10.0,
|
||||
topWavePosition?.y ?? 0.5,
|
||||
topWavePosition?.rotate ?? -0.4,
|
||||
),
|
||||
},
|
||||
middleWavePosition: {
|
||||
value: new Vector3(
|
||||
middleWavePosition?.x ?? 5.0,
|
||||
middleWavePosition?.y ?? 0.0,
|
||||
middleWavePosition?.rotate ?? 0.2,
|
||||
),
|
||||
},
|
||||
bottomWavePosition: {
|
||||
value: new Vector3(
|
||||
bottomWavePosition?.x ?? 2.0,
|
||||
bottomWavePosition?.y ?? -0.7,
|
||||
bottomWavePosition?.rotate ?? 0.4,
|
||||
),
|
||||
},
|
||||
|
||||
iMouse: { value: new Vector2(-1000, -1000) },
|
||||
interactive: { value: interactive },
|
||||
bendRadius: { value: bendRadius },
|
||||
bendStrength: { value: bendStrength },
|
||||
bendInfluence: { value: 0 },
|
||||
|
||||
parallax: { value: parallax },
|
||||
parallaxStrength: { value: parallaxStrength },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
||||
lineGradient: {
|
||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
||||
},
|
||||
lineGradientCount: { value: 0 },
|
||||
}
|
||||
|
||||
if (linesGradient && linesGradient.length > 0) {
|
||||
const stops = linesGradient.slice(0, MAX_GRADIENT_STOPS)
|
||||
this.uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const color = hexToVec3(hex)
|
||||
this.uniforms.lineGradient.value[i].set(color.x, color.y, color.z)
|
||||
})
|
||||
}
|
||||
|
||||
const material = new ShaderMaterial({
|
||||
uniforms: this.uniforms,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
})
|
||||
|
||||
const geometry = new PlaneGeometry(2, 2)
|
||||
this.mesh = new Mesh(geometry, material)
|
||||
this.scene.add(this.mesh)
|
||||
|
||||
this.geometry = geometry
|
||||
this.material = material
|
||||
this.clock = new Clock()
|
||||
|
||||
this._setSize = () => {
|
||||
const width = container.clientWidth || 1
|
||||
const height = container.clientHeight || 1
|
||||
this.renderer.setSize(width, height, false)
|
||||
const canvasWidth = this.renderer.domElement.width
|
||||
const canvasHeight = this.renderer.domElement.height
|
||||
this.uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
||||
}
|
||||
this._setSize()
|
||||
|
||||
this.ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(this._setSize) : null
|
||||
if (this.ro) this.ro.observe(container)
|
||||
|
||||
this._handlePointerMove = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
const dpr = this.renderer.getPixelRatio()
|
||||
|
||||
this.targetMouse.set(x * dpr, (rect.height - y) * dpr)
|
||||
this.targetInfluence = 1.0
|
||||
|
||||
if (this.parallax) {
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
const offsetX = (x - centerX) / rect.width
|
||||
const offsetY = -(y - centerY) / rect.height
|
||||
this.targetParallax.set(
|
||||
offsetX * this.parallaxStrength,
|
||||
offsetY * this.parallaxStrength,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this._handlePointerLeave = () => {
|
||||
this.targetInfluence = 0.0
|
||||
}
|
||||
|
||||
this.renderer.domElement.addEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.addEventListener('pointerleave', this._handlePointerLeave)
|
||||
|
||||
this.raf = 0
|
||||
const renderLoop = () => {
|
||||
this.uniforms.iTime.value = this.clock.getElapsedTime()
|
||||
|
||||
if (this.interactive) {
|
||||
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
|
||||
this.uniforms.iMouse.value.copy(this.currentMouse)
|
||||
|
||||
this.currentInfluence += (this.targetInfluence - this.currentInfluence) * this.mouseDamping
|
||||
this.uniforms.bendInfluence.value = this.currentInfluence
|
||||
}
|
||||
|
||||
if (this.parallax) {
|
||||
this.currentParallax.lerp(this.targetParallax, this.mouseDamping)
|
||||
this.uniforms.parallaxOffset.value.copy(this.currentParallax)
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.raf = requestAnimationFrame(renderLoop)
|
||||
}
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this.raf)
|
||||
if (this.ro) this.ro.disconnect()
|
||||
|
||||
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
|
||||
|
||||
this.geometry.dispose()
|
||||
this.material.dispose()
|
||||
this.renderer.dispose()
|
||||
|
||||
if (this.renderer.domElement.parentElement) {
|
||||
this.renderer.domElement.parentElement.removeChild(this.renderer.domElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
457
frontend/dev/floating-lines copy.js
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
import {
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
WebGLRenderer,
|
||||
PlaneGeometry,
|
||||
Mesh,
|
||||
ShaderMaterial,
|
||||
Vector3,
|
||||
Vector2,
|
||||
Clock,
|
||||
} from 'three'
|
||||
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float animationSpeed;
|
||||
|
||||
uniform bool enableTop;
|
||||
uniform bool enableMiddle;
|
||||
uniform bool enableBottom;
|
||||
|
||||
uniform int topLineCount;
|
||||
uniform int middleLineCount;
|
||||
uniform int bottomLineCount;
|
||||
|
||||
uniform float topLineDistance;
|
||||
uniform float middleLineDistance;
|
||||
uniform float bottomLineDistance;
|
||||
|
||||
uniform vec3 topWavePosition;
|
||||
uniform vec3 middleWavePosition;
|
||||
uniform vec3 bottomWavePosition;
|
||||
|
||||
uniform vec2 iMouse;
|
||||
uniform bool interactive;
|
||||
uniform float bendRadius;
|
||||
uniform float bendStrength;
|
||||
uniform float bendInfluence;
|
||||
|
||||
uniform bool parallax;
|
||||
uniform float parallaxStrength;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
|
||||
const vec3 BLACK = vec3(0.0);
|
||||
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
||||
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
||||
|
||||
mat2 rotate(float r) {
|
||||
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
||||
}
|
||||
|
||||
vec3 background_color(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
||||
float m = uv.y - y;
|
||||
|
||||
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
||||
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
||||
return col * 0.5;
|
||||
}
|
||||
|
||||
vec3 getLineColor(float t, vec3 baseColor) {
|
||||
if (lineGradientCount <= 0) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
vec3 gradientColor;
|
||||
|
||||
if (lineGradientCount == 1) {
|
||||
gradientColor = lineGradient[0];
|
||||
} else {
|
||||
float clampedT = clamp(t, 0.0, 0.9999);
|
||||
float scaled = clampedT * float(lineGradientCount - 1);
|
||||
int idx = int(floor(scaled));
|
||||
float f = fract(scaled);
|
||||
int idx2 = min(idx + 1, lineGradientCount - 1);
|
||||
|
||||
vec3 c1 = lineGradient[idx];
|
||||
vec3 c2 = lineGradient[idx2];
|
||||
|
||||
gradientColor = mix(c1, c2, f);
|
||||
}
|
||||
|
||||
return gradientColor * 0.5;
|
||||
}
|
||||
|
||||
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
|
||||
float time = iTime * animationSpeed;
|
||||
|
||||
float x_offset = offset;
|
||||
float x_movement = time * 0.1;
|
||||
float amp = sin(offset + time * 0.2) * 0.3;
|
||||
float y = sin(uv.x + x_offset + x_movement) * amp;
|
||||
|
||||
if (shouldBend) {
|
||||
vec2 d = screenUv - mouseUv;
|
||||
float influence = exp(-dot(d, d) * bendRadius);
|
||||
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
|
||||
y += bendOffset;
|
||||
}
|
||||
|
||||
float m = uv.y - y;
|
||||
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0;
|
||||
|
||||
if (parallax) {
|
||||
baseUv += parallaxOffset;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec3 b = lineGradientCount > 0 ? vec3(0.0) : background_color(baseUv);
|
||||
|
||||
vec2 mouseUv = vec2(0.0);
|
||||
if (interactive) {
|
||||
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
||||
mouseUv.y *= -1.0;
|
||||
}
|
||||
|
||||
if (enableBottom) {
|
||||
for (int i = 0; i < bottomLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(bottomLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
|
||||
1.5 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableMiddle) {
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(middleLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = middleWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(middleLineDistance * fi + middleWavePosition.x, middleWavePosition.y),
|
||||
2.0 + 0.15 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableTop) {
|
||||
for (int i = 0; i < topLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(topLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
ruv.x *= -1.0;
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
|
||||
1.0 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
fragColor = vec4(col, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = vec4(0.0);
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_GRADIENT_STOPS = 8
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let value = hex.trim()
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
value = value.slice(1)
|
||||
}
|
||||
|
||||
let r = 255
|
||||
let g = 255
|
||||
let b = 255
|
||||
|
||||
if (value.length === 3) {
|
||||
r = parseInt(value[0] + value[0], 16)
|
||||
g = parseInt(value[1] + value[1], 16)
|
||||
b = parseInt(value[2] + value[2], 16)
|
||||
} else if (value.length === 6) {
|
||||
r = parseInt(value.slice(0, 2), 16)
|
||||
g = parseInt(value.slice(2, 4), 16)
|
||||
b = parseInt(value.slice(4, 6), 16)
|
||||
}
|
||||
|
||||
return new Vector3(r / 255, g / 255, b / 255)
|
||||
}
|
||||
|
||||
export default class FloatingLines {
|
||||
constructor(
|
||||
container,
|
||||
{
|
||||
linesGradient,
|
||||
enabledWaves = ['top', 'middle', 'bottom'],
|
||||
lineCount = [6],
|
||||
lineDistance = [5],
|
||||
topWavePosition,
|
||||
middleWavePosition,
|
||||
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
||||
animationSpeed = 1,
|
||||
interactive = true,
|
||||
bendRadius = 5.0,
|
||||
bendStrength = -0.5,
|
||||
mouseDamping = 0.05,
|
||||
parallax = true,
|
||||
parallaxStrength = 0.2,
|
||||
mixBlendMode = 'screen',
|
||||
} = {},
|
||||
) {
|
||||
this.container = container
|
||||
this.interactive = interactive
|
||||
this.parallax = parallax
|
||||
this.mouseDamping = mouseDamping
|
||||
|
||||
this.targetMouse = new Vector2(-1000, -1000)
|
||||
this.currentMouse = new Vector2(-1000, -1000)
|
||||
this.targetInfluence = 0
|
||||
this.currentInfluence = 0
|
||||
this.targetParallax = new Vector2(0, 0)
|
||||
this.currentParallax = new Vector2(0, 0)
|
||||
|
||||
const getLineCount = (waveType) => {
|
||||
if (typeof lineCount === 'number') return lineCount
|
||||
if (!enabledWaves.includes(waveType)) return 0
|
||||
const index = enabledWaves.indexOf(waveType)
|
||||
return lineCount[index] ?? 6
|
||||
}
|
||||
|
||||
const getLineDistance = (waveType) => {
|
||||
if (typeof lineDistance === 'number') return lineDistance
|
||||
if (!enabledWaves.includes(waveType)) return 0.1
|
||||
const index = enabledWaves.indexOf(waveType)
|
||||
return lineDistance[index] ?? 0.1
|
||||
}
|
||||
|
||||
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0
|
||||
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0
|
||||
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0
|
||||
|
||||
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
|
||||
const middleLineDistance = enabledWaves.includes('middle')
|
||||
? getLineDistance('middle') * 0.01
|
||||
: 0.01
|
||||
const bottomLineDistance = enabledWaves.includes('bottom')
|
||||
? getLineDistance('bottom') * 0.01
|
||||
: 0.01
|
||||
|
||||
this.scene = new Scene()
|
||||
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
this.camera.position.z = 1
|
||||
|
||||
this.renderer = new WebGLRenderer({ antialias: true, alpha: false })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.domElement.style.width = '100%'
|
||||
this.renderer.domElement.style.height = '100%'
|
||||
this.renderer.domElement.style.mixBlendMode = mixBlendMode
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
this.uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: animationSpeed },
|
||||
|
||||
enableTop: { value: enabledWaves.includes('top') },
|
||||
enableMiddle: { value: enabledWaves.includes('middle') },
|
||||
enableBottom: { value: enabledWaves.includes('bottom') },
|
||||
|
||||
topLineCount: { value: topLineCount },
|
||||
middleLineCount: { value: middleLineCount },
|
||||
bottomLineCount: { value: bottomLineCount },
|
||||
|
||||
topLineDistance: { value: topLineDistance },
|
||||
middleLineDistance: { value: middleLineDistance },
|
||||
bottomLineDistance: { value: bottomLineDistance },
|
||||
|
||||
topWavePosition: {
|
||||
value: new Vector3(
|
||||
topWavePosition?.x ?? 10.0,
|
||||
topWavePosition?.y ?? 0.5,
|
||||
topWavePosition?.rotate ?? -0.4,
|
||||
),
|
||||
},
|
||||
middleWavePosition: {
|
||||
value: new Vector3(
|
||||
middleWavePosition?.x ?? 5.0,
|
||||
middleWavePosition?.y ?? 0.0,
|
||||
middleWavePosition?.rotate ?? 0.2,
|
||||
),
|
||||
},
|
||||
bottomWavePosition: {
|
||||
value: new Vector3(
|
||||
bottomWavePosition?.x ?? 2.0,
|
||||
bottomWavePosition?.y ?? -0.7,
|
||||
bottomWavePosition?.rotate ?? 0.4,
|
||||
),
|
||||
},
|
||||
|
||||
iMouse: { value: new Vector2(-1000, -1000) },
|
||||
interactive: { value: interactive },
|
||||
bendRadius: { value: bendRadius },
|
||||
bendStrength: { value: bendStrength },
|
||||
bendInfluence: { value: 0 },
|
||||
|
||||
parallax: { value: parallax },
|
||||
parallaxStrength: { value: parallaxStrength },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
||||
lineGradient: {
|
||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
||||
},
|
||||
lineGradientCount: { value: 0 },
|
||||
}
|
||||
|
||||
if (linesGradient && linesGradient.length > 0) {
|
||||
const stops = linesGradient.slice(0, MAX_GRADIENT_STOPS)
|
||||
this.uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const color = hexToVec3(hex)
|
||||
this.uniforms.lineGradient.value[i].set(color.x, color.y, color.z)
|
||||
})
|
||||
}
|
||||
|
||||
const material = new ShaderMaterial({
|
||||
uniforms: this.uniforms,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
})
|
||||
|
||||
const geometry = new PlaneGeometry(2, 2)
|
||||
this.mesh = new Mesh(geometry, material)
|
||||
this.scene.add(this.mesh)
|
||||
|
||||
this.geometry = geometry
|
||||
this.material = material
|
||||
this.clock = new Clock()
|
||||
|
||||
this._setSize = () => {
|
||||
const width = container.clientWidth || 1
|
||||
const height = container.clientHeight || 1
|
||||
this.renderer.setSize(width, height, false)
|
||||
const canvasWidth = this.renderer.domElement.width
|
||||
const canvasHeight = this.renderer.domElement.height
|
||||
this.uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
||||
}
|
||||
this._setSize()
|
||||
|
||||
this.ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(this._setSize) : null
|
||||
if (this.ro) this.ro.observe(container)
|
||||
|
||||
this._handlePointerMove = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
const dpr = this.renderer.getPixelRatio()
|
||||
|
||||
this.targetMouse.set(x * dpr, (rect.height - y) * dpr)
|
||||
this.targetInfluence = 1.0
|
||||
|
||||
if (parallax) {
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
const offsetX = (x - centerX) / rect.width
|
||||
const offsetY = -(y - centerY) / rect.height
|
||||
this.targetParallax.set(offsetX * parallaxStrength, offsetY * parallaxStrength)
|
||||
}
|
||||
}
|
||||
|
||||
this._handlePointerLeave = () => {
|
||||
this.targetInfluence = 0.0
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
this.renderer.domElement.addEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.addEventListener('pointerleave', this._handlePointerLeave)
|
||||
}
|
||||
|
||||
this.raf = 0
|
||||
const renderLoop = () => {
|
||||
this.uniforms.iTime.value = this.clock.getElapsedTime()
|
||||
|
||||
if (interactive) {
|
||||
this.currentMouse.lerp(this.targetMouse, mouseDamping)
|
||||
this.uniforms.iMouse.value.copy(this.currentMouse)
|
||||
|
||||
this.currentInfluence += (this.targetInfluence - this.currentInfluence) * mouseDamping
|
||||
this.uniforms.bendInfluence.value = this.currentInfluence
|
||||
}
|
||||
|
||||
if (parallax) {
|
||||
this.currentParallax.lerp(this.targetParallax, mouseDamping)
|
||||
this.uniforms.parallaxOffset.value.copy(this.currentParallax)
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.raf = requestAnimationFrame(renderLoop)
|
||||
}
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this.raf)
|
||||
if (this.ro) this.ro.disconnect()
|
||||
|
||||
if (this.interactive) {
|
||||
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
|
||||
}
|
||||
|
||||
this.geometry.dispose()
|
||||
this.material.dispose()
|
||||
this.renderer.dispose()
|
||||
|
||||
if (this.renderer.domElement.parentElement) {
|
||||
this.renderer.domElement.parentElement.removeChild(this.renderer.domElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
610
frontend/dev/floating-lines.js
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
import {
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
WebGLRenderer,
|
||||
PlaneGeometry,
|
||||
Mesh,
|
||||
ShaderMaterial,
|
||||
Vector3,
|
||||
Vector2,
|
||||
Clock,
|
||||
} from 'three'
|
||||
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float animationSpeed;
|
||||
|
||||
uniform bool enableTop;
|
||||
uniform bool enableMiddle;
|
||||
uniform bool enableBottom;
|
||||
|
||||
uniform int topLineCount;
|
||||
uniform int middleLineCount;
|
||||
uniform int bottomLineCount;
|
||||
|
||||
uniform float topLineDistance;
|
||||
uniform float bottomLineDistance;
|
||||
|
||||
uniform vec3 topWavePosition;
|
||||
uniform vec3 bottomWavePosition;
|
||||
|
||||
uniform int numPoints;
|
||||
uniform float pointSpacingX;
|
||||
uniform float pointsOffsetX;
|
||||
uniform float pointY[8];
|
||||
uniform float lineSpread;
|
||||
uniform float fanSpread;
|
||||
uniform float lineSharpness;
|
||||
uniform float waveFrequency;
|
||||
uniform float bezierCurvature;
|
||||
uniform float circleRadiusPx;
|
||||
uniform float circleGlowSize;
|
||||
uniform float circleGlowStrength;
|
||||
|
||||
uniform vec2 iMouse;
|
||||
uniform bool interactive;
|
||||
uniform float bendRadius;
|
||||
uniform float bendStrength;
|
||||
uniform float bendInfluence;
|
||||
|
||||
uniform bool parallax;
|
||||
uniform float parallaxStrength;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
uniform vec3 bgColorCenter;
|
||||
uniform vec3 bgColorEdge;
|
||||
|
||||
const vec3 BLACK = vec3(0.0);
|
||||
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
||||
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
||||
|
||||
mat2 rotate(float r) {
|
||||
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
||||
}
|
||||
|
||||
vec3 background_color(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
||||
float m = uv.y - y;
|
||||
|
||||
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
||||
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
||||
return col * 0.5;
|
||||
}
|
||||
|
||||
vec3 getLineColor(float t, vec3 baseColor) {
|
||||
if (lineGradientCount <= 0) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
vec3 gradientColor;
|
||||
|
||||
if (lineGradientCount == 1) {
|
||||
gradientColor = lineGradient[0];
|
||||
} else {
|
||||
float clampedT = clamp(t, 0.0, 0.9999);
|
||||
float scaled = clampedT * float(lineGradientCount - 1);
|
||||
int idx = int(floor(scaled));
|
||||
float f = fract(scaled);
|
||||
int idx2 = min(idx + 1, lineGradientCount - 1);
|
||||
|
||||
vec3 c1 = lineGradient[idx];
|
||||
vec3 c2 = lineGradient[idx2];
|
||||
|
||||
gradientColor = mix(c1, c2, f);
|
||||
}
|
||||
|
||||
return gradientColor * 0.5;
|
||||
}
|
||||
|
||||
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
|
||||
float d = length(uv - center);
|
||||
|
||||
// Glow: Größe und Stärke per Uniform steuerbar
|
||||
float glowW = circleGlowSize / iResolution.y * 2.0;
|
||||
float glow = exp(-pow(max(d - r, 0.0) / glowW, 2.0)) * circleGlowStrength;
|
||||
float fog = 0.008 / max(d * d * 3.0 + 0.016, 0.001);
|
||||
|
||||
// Weißer Kreis: harte Kante, 1px Antialiasing
|
||||
float aa = 1.5 / iResolution.y;
|
||||
float core = 1.0 - smoothstep(r - aa, r + aa, d);
|
||||
|
||||
// Glow nur außerhalb des weißen Kreises
|
||||
vec3 result = color * (glow + fog) * (1.0 - core);
|
||||
result += vec3(core);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Nächsten t-Parameter auf quadratischer Bézier (Newton + Coarse-Search)
|
||||
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
|
||||
// Grobe Suche über 8 Samples für guten Startwert
|
||||
float bestT = 0.0;
|
||||
float bestD = 1e9;
|
||||
for (int k = 0; k <= 8; ++k) {
|
||||
float t = float(k) / 8.0;
|
||||
float mt = 1.0 - t;
|
||||
vec2 b = mt*mt*p0 + 2.0*mt*t*pc + t*t*p1;
|
||||
float d = dot(q - b, q - b);
|
||||
if (d < bestD) { bestD = d; bestT = t; }
|
||||
}
|
||||
|
||||
// Newton-Verfahren: minimiert |B(t)-q|²
|
||||
// f(t) = a·t³ + b·t² + c·t + d, f'(t) = 3a·t² + 2b·t + c
|
||||
vec2 A = pc - p0;
|
||||
vec2 B = p0 - 2.0*pc + p1;
|
||||
vec2 D = p0 - q;
|
||||
float a = 2.0*dot(B,B);
|
||||
float bco = 6.0*dot(A,B);
|
||||
float c = 4.0*dot(A,A) + 2.0*dot(D,B);
|
||||
float dco = 2.0*dot(D,A);
|
||||
|
||||
// Etwas breiterer Bereich erlaubt leichten Überlauf in benachbarte Segmente
|
||||
float t = clamp(bestT, 0.001, 0.999);
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
float f = a*t*t*t + bco*t*t + c*t + dco;
|
||||
float fp = 3.0*a*t*t + 2.0*bco*t + c;
|
||||
if (abs(fp) > 1e-8) t -= f / fp;
|
||||
t = clamp(t, -0.08, 1.08);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) {
|
||||
// Bézier-Kontrollpunkt: Mittelpunkt + senkrechter Versatz
|
||||
vec2 seg = ep - sp;
|
||||
float segLen = length(seg);
|
||||
if (segLen < 0.001) return 0.0;
|
||||
vec2 segDir = seg / segLen;
|
||||
vec2 segPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + segPerp * segLen * bezierCurvature;
|
||||
|
||||
float t = bezierClosestT(uv, sp, pc, ep);
|
||||
float mt = 1.0 - t;
|
||||
|
||||
// Position und Tangente auf der Kurve
|
||||
vec2 curvePos = mt*mt*sp + 2.0*mt*t*pc + t*t*ep;
|
||||
vec2 tang = normalize(2.0*mt*(pc - sp) + 2.0*t*(ep - pc));
|
||||
vec2 norm = vec2(-tang.y, tang.x);
|
||||
|
||||
// Senkrechter Abstand von der Kurve
|
||||
float s = dot(uv - curvePos, norm);
|
||||
|
||||
float time = iTime * animationSpeed;
|
||||
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
|
||||
|
||||
float envelope = sin(t * 3.14159265359);
|
||||
float linePos = (normalizedI - 0.5) * fanSpread * envelope;
|
||||
float amp = lineSpread * 0.3 * envelope;
|
||||
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
|
||||
* sin(fi * 0.9 + time * 0.18);
|
||||
|
||||
float dist = s - linePos - waveDisp;
|
||||
float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
|
||||
|
||||
return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003);
|
||||
}
|
||||
|
||||
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
|
||||
float time = iTime * animationSpeed;
|
||||
|
||||
float x_offset = offset;
|
||||
float x_movement = time * 0.1;
|
||||
float amp = sin(offset + time * 0.2) * 0.3;
|
||||
float y = sin(uv.x + x_offset + x_movement) * amp;
|
||||
|
||||
if (shouldBend) {
|
||||
vec2 d = screenUv - mouseUv;
|
||||
float influence = exp(-dot(d, d) * bendRadius);
|
||||
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
|
||||
y += bendOffset;
|
||||
}
|
||||
|
||||
float m = uv.y - y;
|
||||
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0;
|
||||
|
||||
if (parallax) {
|
||||
baseUv += parallaxOffset;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv);
|
||||
|
||||
vec2 mouseUv = vec2(0.0);
|
||||
if (interactive) {
|
||||
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
||||
mouseUv.y *= -1.0;
|
||||
}
|
||||
|
||||
if (enableBottom) {
|
||||
for (int i = 0; i < bottomLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(bottomLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
|
||||
1.5 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableMiddle) {
|
||||
const int MAX_PTS = 8;
|
||||
const int MAX_SEGS = 7;
|
||||
float r = circleRadiusPx / iResolution.y * 2.0;
|
||||
float tScale = numPoints > 1 ? 1.0 / float(numPoints - 1) : 1.0;
|
||||
|
||||
// Segmente: Punkt s → Punkt s+1
|
||||
for (int s = 0; s < MAX_SEGS; ++s) {
|
||||
if (s >= numPoints - 1) break;
|
||||
|
||||
float x0 = pointsOffsetX + (float(s) - float(numPoints - 1) * 0.5) * pointSpacingX;
|
||||
float x1 = pointsOffsetX + (float(s + 1) - float(numPoints - 1) * 0.5) * pointSpacingX;
|
||||
|
||||
vec2 sp = vec2(x0, pointY[s]);
|
||||
vec2 ep = vec2(x1, pointY[s + 1]);
|
||||
|
||||
// Gradient: globaler t-Bereich [s, s+1] / (numPoints-1)
|
||||
vec2 pd = ep - sp;
|
||||
float pl = length(pd);
|
||||
vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0);
|
||||
float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.0);
|
||||
float t_global = (float(s) + t_seg) * tScale;
|
||||
vec3 lineCol = getLineColor(t_global, b);
|
||||
|
||||
// Bézier-Kontrollpunkt für Nebel (gleiche Logik wie in waveFocal)
|
||||
vec2 segD = ep - sp;
|
||||
float segL = length(segD);
|
||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||
|
||||
// Weicher Nebel entlang der Bézier-Kurve → füllt dunkle Winkel organisch
|
||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||
float bmt = 1.0 - bt;
|
||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
||||
float bDist = length(baseUv - bPos);
|
||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||
float fogEnv = sin(bt * 3.14159265359);
|
||||
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
|
||||
col += lineCol * segFog;
|
||||
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep);
|
||||
}
|
||||
}
|
||||
|
||||
// Kreise an jedem Punkt
|
||||
for (int p = 0; p < MAX_PTS; ++p) {
|
||||
if (p >= numPoints) break;
|
||||
float px = pointsOffsetX + (float(p) - float(numPoints - 1) * 0.5) * pointSpacingX;
|
||||
float t_pt = numPoints > 1 ? float(p) * tScale : 0.0;
|
||||
vec3 circCol = getLineColor(t_pt, b) * 2.5;
|
||||
col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableTop) {
|
||||
for (int i = 0; i < topLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(topLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
ruv.x *= -1.0;
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
|
||||
1.0 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// Hintergrundverlauf: radial von bgColorCenter (Mitte) nach bgColorEdge (Rand)
|
||||
float dist = length(baseUv) / 1.8;
|
||||
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
|
||||
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = vec4(0.0);
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_GRADIENT_STOPS = 8
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let value = hex.trim()
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
value = value.slice(1)
|
||||
}
|
||||
|
||||
let r = 255
|
||||
let g = 255
|
||||
let b = 255
|
||||
|
||||
if (value.length === 3) {
|
||||
r = parseInt(value[0] + value[0], 16)
|
||||
g = parseInt(value[1] + value[1], 16)
|
||||
b = parseInt(value[2] + value[2], 16)
|
||||
} else if (value.length === 6) {
|
||||
r = parseInt(value.slice(0, 2), 16)
|
||||
g = parseInt(value.slice(2, 4), 16)
|
||||
b = parseInt(value.slice(4, 6), 16)
|
||||
}
|
||||
|
||||
return new Vector3(r / 255, g / 255, b / 255)
|
||||
}
|
||||
|
||||
export default class FloatingLines {
|
||||
constructor(
|
||||
container,
|
||||
{
|
||||
linesGradient,
|
||||
enabledWaves = ['top', 'middle', 'bottom'],
|
||||
lineCount = [6],
|
||||
lineDistance = [5],
|
||||
topWavePosition,
|
||||
middleWavePosition,
|
||||
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
||||
numPoints = 4,
|
||||
pointSpacingX = 0.8,
|
||||
pointsOffsetX = 0.0,
|
||||
pointYValues = [0.75, -0.5, 0.3, -0.75, 0.6, -0.4, 0.5, -0.6],
|
||||
lineSpread = 0.6,
|
||||
fanSpread = 0.25,
|
||||
lineSharpness = 1.0,
|
||||
waveFrequency = 8.0,
|
||||
bezierCurvature = 0.3,
|
||||
circleRadiusPx = 50,
|
||||
animationSpeed = 1,
|
||||
interactive = true,
|
||||
bendRadius = 5.0,
|
||||
bendStrength = -0.5,
|
||||
mouseDamping = 0.05,
|
||||
parallax = true,
|
||||
parallaxStrength = 0.2,
|
||||
circleGlowSize = 18.0,
|
||||
circleGlowStrength = 1.5,
|
||||
mixBlendMode = 'screen',
|
||||
} = {},
|
||||
) {
|
||||
this.container = container
|
||||
this.interactive = interactive
|
||||
this.parallax = parallax
|
||||
this.mouseDamping = mouseDamping
|
||||
this.parallaxStrength = parallaxStrength
|
||||
|
||||
this.targetMouse = new Vector2(-1000, -1000)
|
||||
this.currentMouse = new Vector2(-1000, -1000)
|
||||
this.targetInfluence = 0
|
||||
this.currentInfluence = 0
|
||||
this.targetParallax = new Vector2(0, 0)
|
||||
this.currentParallax = new Vector2(0, 0)
|
||||
|
||||
const getLineCount = (waveType) => {
|
||||
if (typeof lineCount === 'number') return lineCount
|
||||
if (!enabledWaves.includes(waveType)) return 0
|
||||
const index = enabledWaves.indexOf(waveType)
|
||||
return lineCount[index] ?? 6
|
||||
}
|
||||
|
||||
const getLineDistance = (waveType) => {
|
||||
if (typeof lineDistance === 'number') return lineDistance
|
||||
if (!enabledWaves.includes(waveType)) return 0.1
|
||||
const index = enabledWaves.indexOf(waveType)
|
||||
return lineDistance[index] ?? 0.1
|
||||
}
|
||||
|
||||
const topLineCount = enabledWaves.includes('top') ? getLineCount('top') : 0
|
||||
const middleLineCount = enabledWaves.includes('middle') ? getLineCount('middle') : 0
|
||||
const bottomLineCount = enabledWaves.includes('bottom') ? getLineCount('bottom') : 0
|
||||
|
||||
const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
|
||||
const bottomLineDistance = enabledWaves.includes('bottom')
|
||||
? getLineDistance('bottom') * 0.01
|
||||
: 0.01
|
||||
|
||||
this.scene = new Scene()
|
||||
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
|
||||
this.camera.position.z = 1
|
||||
|
||||
this.renderer = new WebGLRenderer({ antialias: true, alpha: false })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.domElement.style.width = '100%'
|
||||
this.renderer.domElement.style.height = '100%'
|
||||
this.renderer.domElement.style.mixBlendMode = mixBlendMode
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
this.uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: animationSpeed },
|
||||
|
||||
enableTop: { value: enabledWaves.includes('top') },
|
||||
enableMiddle: { value: enabledWaves.includes('middle') },
|
||||
enableBottom: { value: enabledWaves.includes('bottom') },
|
||||
|
||||
topLineCount: { value: topLineCount },
|
||||
middleLineCount: { value: middleLineCount },
|
||||
bottomLineCount: { value: bottomLineCount },
|
||||
|
||||
topLineDistance: { value: topLineDistance },
|
||||
bottomLineDistance: { value: bottomLineDistance },
|
||||
|
||||
topWavePosition: {
|
||||
value: new Vector3(
|
||||
topWavePosition?.x ?? 10.0,
|
||||
topWavePosition?.y ?? 0.5,
|
||||
topWavePosition?.rotate ?? -0.4,
|
||||
),
|
||||
},
|
||||
bottomWavePosition: {
|
||||
value: new Vector3(
|
||||
bottomWavePosition?.x ?? 2.0,
|
||||
bottomWavePosition?.y ?? -0.7,
|
||||
bottomWavePosition?.rotate ?? 0.4,
|
||||
),
|
||||
},
|
||||
|
||||
numPoints: { value: numPoints },
|
||||
pointSpacingX: { value: pointSpacingX },
|
||||
pointsOffsetX: { value: pointsOffsetX },
|
||||
pointY: { value: [...pointYValues].slice(0, 8).concat(Array(8).fill(0)).slice(0, 8) },
|
||||
lineSpread: { value: lineSpread },
|
||||
fanSpread: { value: fanSpread },
|
||||
lineSharpness: { value: lineSharpness },
|
||||
waveFrequency: { value: waveFrequency },
|
||||
bezierCurvature: { value: bezierCurvature },
|
||||
circleRadiusPx: { value: circleRadiusPx },
|
||||
circleGlowSize: { value: circleGlowSize },
|
||||
circleGlowStrength: { value: circleGlowStrength },
|
||||
|
||||
iMouse: { value: new Vector2(-1000, -1000) },
|
||||
interactive: { value: interactive },
|
||||
bendRadius: { value: bendRadius },
|
||||
bendStrength: { value: bendStrength },
|
||||
bendInfluence: { value: 0 },
|
||||
|
||||
parallax: { value: parallax },
|
||||
parallaxStrength: { value: parallaxStrength },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
||||
lineGradient: {
|
||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
||||
},
|
||||
lineGradientCount: { value: 0 },
|
||||
bgColor: { value: new Vector3(0, 0, 0) },
|
||||
}
|
||||
|
||||
if (linesGradient && linesGradient.length > 0) {
|
||||
const stops = linesGradient.slice(0, MAX_GRADIENT_STOPS)
|
||||
this.uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const color = hexToVec3(hex)
|
||||
this.uniforms.lineGradient.value[i].set(color.x, color.y, color.z)
|
||||
})
|
||||
}
|
||||
|
||||
const material = new ShaderMaterial({
|
||||
uniforms: this.uniforms,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
})
|
||||
|
||||
const geometry = new PlaneGeometry(2, 2)
|
||||
this.mesh = new Mesh(geometry, material)
|
||||
this.scene.add(this.mesh)
|
||||
|
||||
this.geometry = geometry
|
||||
this.material = material
|
||||
this.clock = new Clock()
|
||||
|
||||
this._setSize = () => {
|
||||
const width = container.clientWidth || 1
|
||||
const height = container.clientHeight || 1
|
||||
this.renderer.setSize(width, height, false)
|
||||
const canvasWidth = this.renderer.domElement.width
|
||||
const canvasHeight = this.renderer.domElement.height
|
||||
this.uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
|
||||
}
|
||||
this._setSize()
|
||||
|
||||
this.ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(this._setSize) : null
|
||||
if (this.ro) this.ro.observe(container)
|
||||
|
||||
this._handlePointerMove = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
const dpr = this.renderer.getPixelRatio()
|
||||
|
||||
this.targetMouse.set(x * dpr, (rect.height - y) * dpr)
|
||||
this.targetInfluence = 1.0
|
||||
|
||||
if (this.parallax) {
|
||||
const centerX = rect.width / 2
|
||||
const centerY = rect.height / 2
|
||||
const offsetX = (x - centerX) / rect.width
|
||||
const offsetY = -(y - centerY) / rect.height
|
||||
this.targetParallax.set(offsetX * this.parallaxStrength, offsetY * this.parallaxStrength)
|
||||
}
|
||||
}
|
||||
|
||||
this._handlePointerLeave = () => {
|
||||
this.targetInfluence = 0.0
|
||||
}
|
||||
|
||||
this.renderer.domElement.addEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.addEventListener('pointerleave', this._handlePointerLeave)
|
||||
|
||||
this.raf = 0
|
||||
const renderLoop = () => {
|
||||
this.uniforms.iTime.value = this.clock.getElapsedTime()
|
||||
|
||||
if (this.interactive) {
|
||||
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
|
||||
this.uniforms.iMouse.value.copy(this.currentMouse)
|
||||
|
||||
this.currentInfluence += (this.targetInfluence - this.currentInfluence) * this.mouseDamping
|
||||
this.uniforms.bendInfluence.value = this.currentInfluence
|
||||
}
|
||||
|
||||
if (this.parallax) {
|
||||
this.currentParallax.lerp(this.targetParallax, this.mouseDamping)
|
||||
this.uniforms.parallaxOffset.value.copy(this.currentParallax)
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.raf = requestAnimationFrame(renderLoop)
|
||||
}
|
||||
renderLoop()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this.raf)
|
||||
if (this.ro) this.ro.disconnect()
|
||||
|
||||
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
|
||||
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
|
||||
|
||||
this.geometry.dispose()
|
||||
this.material.dispose()
|
||||
this.renderer.dispose()
|
||||
|
||||
if (this.renderer.domElement.parentElement) {
|
||||
this.renderer.domElement.parentElement.removeChild(this.renderer.domElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
279
frontend/dev/init-fl copy.html
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FloatingLines Dev</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────────── */
|
||||
#controls {
|
||||
flex-shrink: 0;
|
||||
background: #0d0d0f;
|
||||
border-top: 1px solid #222;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 16px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctrl-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ctrl-group h3 {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.row label {
|
||||
font-size: 10px;
|
||||
color: #777;
|
||||
white-space: nowrap;
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 3px;
|
||||
accent-color: #a855f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row .val {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="container"></div>
|
||||
|
||||
<footer id="controls">
|
||||
<!-- Col 1: Allgemein -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Allgemein</h3>
|
||||
<div class="row">
|
||||
<label for="speed">Speed</label>
|
||||
<input type="range" id="speed" min="0.1" max="3" step="0.05" value="1" />
|
||||
<span class="val" id="speed-val">1.00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 2: Middle Wave -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Middle Wave</h3>
|
||||
<div class="row">
|
||||
<label for="midCount">Lines</label>
|
||||
<input type="range" id="midCount" min="0" max="40" step="1" value="15" />
|
||||
<span class="val" id="midCount-val">10</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="midDist">Distance</label>
|
||||
<input type="range" id="midDist" min="1" max="30" step="0.5" value="6" />
|
||||
<span class="val" id="midDist-val">6.0</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="midX">X</label>
|
||||
<input type="range" id="midX" min="-20" max="20" step="0.1" value="5" />
|
||||
<span class="val" id="midX-val">5.0</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="midY">Y</label>
|
||||
<input type="range" id="midY" min="-2" max="2" step="0.05" value="0" />
|
||||
<span class="val" id="midY-val">0.00</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="midR">Rotate</label>
|
||||
<input type="range" id="midR" min="-2" max="2" step="0.05" value="0.2" />
|
||||
<span class="val" id="midR-val">0.20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 3: Gradient -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Gradient</h3>
|
||||
<div class="row" style="flex-direction: column; align-items: flex-start; gap: 4px">
|
||||
<label style="color: #555; font-size: 9px">Farbstopps (je Zeile ein Hex)</label>
|
||||
<textarea
|
||||
id="gradientInput"
|
||||
spellcheck="false"
|
||||
style="
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
color: #ccc;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
padding: 4px 6px;
|
||||
resize: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
"
|
||||
>
|
||||
#e947f5
|
||||
#2f4ba2
|
||||
#0a0a12</textarea
|
||||
>
|
||||
<button
|
||||
id="applyGradient"
|
||||
style="
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
color: #a855f7;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
"
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 4px">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="clearGradient"
|
||||
style="accent-color: #a855f7; cursor: pointer; width: 12px; height: 12px"
|
||||
/>
|
||||
<label for="clearGradient" style="cursor: pointer; min-width: unset">
|
||||
Gradient deaktivieren
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import FloatingLines from './floating-lines.js'
|
||||
|
||||
const container = document.getElementById('container')
|
||||
|
||||
const fl = new FloatingLines(container, {
|
||||
enabledWaves: ['middle'],
|
||||
lineCount: [10],
|
||||
lineDistance: [2],
|
||||
interactive: false,
|
||||
parallax: false,
|
||||
linesGradient: ['#e947f5', '#2f4ba2', '#0a0a12'],
|
||||
})
|
||||
|
||||
// ── Hilfsfunktionen ────────────────────────────────────────────────
|
||||
function slider(id, decimals, onChange) {
|
||||
const input = document.getElementById(id)
|
||||
const display = document.getElementById(id + '-val')
|
||||
input.addEventListener('input', () => {
|
||||
const v = parseFloat(input.value)
|
||||
if (display) display.textContent = v.toFixed(decimals)
|
||||
onChange(v)
|
||||
})
|
||||
}
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let v = hex.trim().replace('#', '')
|
||||
if (v.length === 3) v = v[0] + v[0] + v[1] + v[1] + v[2] + v[2]
|
||||
return [
|
||||
parseInt(v.slice(0, 2), 16) / 255,
|
||||
parseInt(v.slice(2, 4), 16) / 255,
|
||||
parseInt(v.slice(4, 6), 16) / 255,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Allgemein ──────────────────────────────────────────────────────
|
||||
slider('speed', 2, (v) => (fl.uniforms.animationSpeed.value = v))
|
||||
|
||||
// ── Middle Wave ────────────────────────────────────────────────────
|
||||
slider('midCount', 0, (v) => (fl.uniforms.middleLineCount.value = Math.round(v)))
|
||||
slider('midDist', 1, (v) => (fl.uniforms.middleLineDistance.value = v * 0.01))
|
||||
slider('midX', 1, (v) => (fl.uniforms.middleWavePosition.value.x = v))
|
||||
slider('midY', 2, (v) => (fl.uniforms.middleWavePosition.value.y = v))
|
||||
slider('midR', 2, (v) => (fl.uniforms.middleWavePosition.value.z = v))
|
||||
|
||||
// ── Gradient ───────────────────────────────────────────────────────
|
||||
const MAX_STOPS = 8
|
||||
|
||||
function applyGradient() {
|
||||
const lines = document
|
||||
.getElementById('gradientInput')
|
||||
.value.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
const stops = lines.slice(0, MAX_STOPS)
|
||||
fl.uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const [r, g, b] = hexToVec3(hex)
|
||||
fl.uniforms.lineGradient.value[i].set(r, g, b)
|
||||
})
|
||||
}
|
||||
|
||||
document.getElementById('applyGradient').addEventListener('click', applyGradient)
|
||||
|
||||
document.getElementById('clearGradient').addEventListener('change', (e) => {
|
||||
fl.uniforms.lineGradientCount.value = e.target.checked
|
||||
? 0
|
||||
: document
|
||||
.getElementById('gradientInput')
|
||||
.value.split('\n')
|
||||
.filter((s) => s.trim()).length
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
522
frontend/dev/init-fl.html
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FloatingLines Dev</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* ── Footer ──────────────────────────────────────────────────────── */
|
||||
#controls {
|
||||
flex-shrink: 0;
|
||||
background: #0d0d0f;
|
||||
border-top: 1px solid #222;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 2fr 1.2fr;
|
||||
gap: 10px 16px;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctrl-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ctrl-group h3 {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.row label {
|
||||
font-size: 10px;
|
||||
color: #777;
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 3px;
|
||||
accent-color: #a855f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row .val {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Punkthöhen-Grid */
|
||||
#point-sliders {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
.point-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.point-row label {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
min-width: 22px;
|
||||
}
|
||||
.point-row input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 3px;
|
||||
accent-color: #a855f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.point-row .val {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.point-row.inactive {
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.img-btn {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #333;
|
||||
color: #888;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.img-btn:hover {
|
||||
border-color: #a855f7;
|
||||
color: #ccc;
|
||||
}
|
||||
.img-btn.active {
|
||||
border-color: #a855f7;
|
||||
color: #a855f7;
|
||||
background: #2a1a3e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="container"></div>
|
||||
|
||||
<footer id="controls">
|
||||
<!-- Col 1: Linien -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Linien</h3>
|
||||
<div class="row">
|
||||
<label for="speed">Speed</label>
|
||||
<input type="range" id="speed" min="0.1" max="3" step="0.05" value="1" />
|
||||
<span class="val" id="speed-val">1.00</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="lineCount">Anzahl</label>
|
||||
<input type="range" id="lineCount" min="1" max="40" step="1" value="10" />
|
||||
<span class="val" id="lineCount-val">10</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="spread">Wellen-Amp</label>
|
||||
<input type="range" id="spread" min="0.01" max="1" step="0.01" value="0.5" />
|
||||
<span class="val" id="spread-val">0.05</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="fanSpread">Fächerbreite</label>
|
||||
<input type="range" id="fanSpread" min="0.01" max="0.5" step="0.005" value="0.05" />
|
||||
<span class="val" id="fanSpread-val">0.5</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="lineSharpness">Feinheit</label>
|
||||
<input type="range" id="lineSharpness" min="0.3" max="10" step="0.1" value="8" />
|
||||
<span class="val" id="lineSharpness-val">8.0</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="waveFreq">Welligkeit</label>
|
||||
<input type="range" id="waveFreq" min="1" max="30" step="0.5" value="7" />
|
||||
<span class="val" id="waveFreq-val">7.0</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="bezierCurv">Kurve</label>
|
||||
<input type="range" id="bezierCurv" min="-1" max="1" step="0.05" value="0.2" />
|
||||
<span class="val" id="bezierCurv-val">0.20</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="circleRadius">Kreis</label>
|
||||
<input type="range" id="circleRadius" min="10" max="200" step="5" value="75" />
|
||||
<span class="val" id="circleRadius-val">75px</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="glowSize">Glow Größe</label>
|
||||
<input type="range" id="glowSize" min="5" max="100" step="1" value="18" />
|
||||
<span class="val" id="glowSize-val">18px</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="glowStrength">Glow Stärke</label>
|
||||
<input type="range" id="glowStrength" min="0.5" max="12" step="0.5" value="1.5" />
|
||||
<span class="val" id="glowStrength-val">1.5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 2: Raster -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Raster</h3>
|
||||
<div class="row">
|
||||
<label for="numPoints">Punkte</label>
|
||||
<input type="range" id="numPoints" min="2" max="8" step="1" value="4" />
|
||||
<span class="val" id="numPoints-val">4</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="spacing">Abstand</label>
|
||||
<input type="range" id="spacing" min="0.1" max="3" step="0.05" value="0.8" />
|
||||
<span class="val" id="spacing-val">0.80</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="offsetX">Versatz X</label>
|
||||
<input type="range" id="offsetX" min="-2" max="2" step="0.05" value="0" />
|
||||
<span class="val" id="offsetX-val">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 3: Punkt-Höhen (2×4 Grid) -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Punkt-Höhen</h3>
|
||||
<div id="point-sliders">
|
||||
<!-- P1–P8, je Zeile: Label + Slider + Wert -->
|
||||
<div class="point-row" id="pr0">
|
||||
<label style="color: #a78bfa">P1</label>
|
||||
<input type="range" id="py0" min="-1.2" max="1.2" step="0.02" value="-0.75" />
|
||||
<span class="val" id="py0-val">-0.75</span>
|
||||
</div>
|
||||
<div class="point-row" id="pr1">
|
||||
<label style="color: #a78bfa">P2</label>
|
||||
<input type="range" id="py1" min="-1.2" max="1.2" step="0.02" value="0.5" />
|
||||
<span class="val" id="py1-val">0.50</span>
|
||||
</div>
|
||||
<div class="point-row" id="pr2">
|
||||
<label style="color: #818cf8">P3</label>
|
||||
<input type="range" id="py2" min="-1.2" max="1.2" step="0.02" value="-0.3" />
|
||||
<span class="val" id="py2-val">-0.30</span>
|
||||
</div>
|
||||
<div class="point-row" id="pr3">
|
||||
<label style="color: #818cf8">P4</label>
|
||||
<input type="range" id="py3" min="-1.2" max="1.2" step="0.02" value="0.75" />
|
||||
<span class="val" id="py3-val">0.75</span>
|
||||
</div>
|
||||
<div class="point-row inactive" id="pr4">
|
||||
<label style="color: #6366f1">P5</label>
|
||||
<input type="range" id="py4" min="-1.2" max="1.2" step="0.02" value="-0.6" />
|
||||
<span class="val" id="py4-val">-0.60</span>
|
||||
</div>
|
||||
<div class="point-row inactive" id="pr5">
|
||||
<label style="color: #6366f1">P6</label>
|
||||
<input type="range" id="py5" min="-1.2" max="1.2" step="0.02" value="0.4" />
|
||||
<span class="val" id="py5-val">0.40</span>
|
||||
</div>
|
||||
<div class="point-row inactive" id="pr6">
|
||||
<label style="color: #4f46e5">P7</label>
|
||||
<input type="range" id="py6" min="-1.2" max="1.2" step="0.02" value="-0.2" />
|
||||
<span class="val" id="py6-val">-0.20</span>
|
||||
</div>
|
||||
<div class="point-row inactive" id="pr7">
|
||||
<label style="color: #4f46e5">P8</label>
|
||||
<input type="range" id="py7" min="-1.2" max="1.2" step="0.02" value="0.8" />
|
||||
<span class="val" id="py7-val">0.80</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 4: Hintergrundbild + Farben -->
|
||||
<div class="ctrl-group">
|
||||
<h3>Hintergrundbild</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px">
|
||||
<button class="img-btn active" data-img="">Keins</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-1.jpg">1</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-2.jpg">2</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-3.jpg">3</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-4.jpg">4</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-5.jpg">5</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-6.jpg">6</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-7.jpg">7</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-8.jpg">8</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-9.jpg">9</button>
|
||||
<button class="img-btn" data-img="../public/images/bg-image-10.jpg">10</button>
|
||||
</div>
|
||||
<h3>Hintergrundfarbe</h3>
|
||||
<div class="row">
|
||||
<label for="bgCenter">BG Mitte</label>
|
||||
<input
|
||||
type="color"
|
||||
id="bgCenter"
|
||||
value="#0a0514"
|
||||
style="
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="bgEdge">BG Rand</label>
|
||||
<input
|
||||
type="color"
|
||||
id="bgEdge"
|
||||
value="#000000"
|
||||
style="
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="row" style="flex-direction: column; align-items: flex-start; gap: 4px">
|
||||
<label style="color: #555; font-size: 9px">Farbstopps (je Zeile ein Hex)</label>
|
||||
<textarea
|
||||
id="gradientInput"
|
||||
spellcheck="false"
|
||||
style="
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
color: #ccc;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
padding: 4px 6px;
|
||||
resize: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
"
|
||||
>
|
||||
#e947f5
|
||||
#2f4ba2
|
||||
#0a0a12</textarea
|
||||
>
|
||||
<button
|
||||
id="applyGradient"
|
||||
style="
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
color: #a855f7;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
"
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import FloatingLines from './floating-lines.js'
|
||||
|
||||
const container = document.getElementById('container')
|
||||
|
||||
// Slider-Y ist screen-intuitiv: +1 = oben = shader-Y negativ → Y-Flip beim Setzen
|
||||
const initPointY = [-0.75, 0.5, -0.3, 0.75, -0.6, 0.4, -0.2, 0.8].map((v) => -v) // flip für shader
|
||||
|
||||
const fl = new FloatingLines(container, {
|
||||
enabledWaves: ['middle'],
|
||||
lineCount: [10],
|
||||
numPoints: 4,
|
||||
pointSpacingX: 0.8,
|
||||
pointsOffsetX: 0.0,
|
||||
pointYValues: initPointY,
|
||||
lineSpread: 0.05,
|
||||
fanSpread: 0.05,
|
||||
lineSharpness: 8.0,
|
||||
waveFrequency: 7.0,
|
||||
circleRadiusPx: 75,
|
||||
circleGlowSize: 18,
|
||||
circleGlowStrength: 1.5,
|
||||
interactive: false,
|
||||
parallax: false,
|
||||
linesGradient: ['#e947f5', '#2f4ba2', '#0a0a12'],
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
function slider(id, decimals, onChange) {
|
||||
const input = document.getElementById(id)
|
||||
const disp = document.getElementById(id + '-val')
|
||||
input.addEventListener('input', () => {
|
||||
const v = parseFloat(input.value)
|
||||
if (disp) disp.textContent = v.toFixed(decimals)
|
||||
onChange(v)
|
||||
})
|
||||
}
|
||||
|
||||
function hexToVec3(hex) {
|
||||
let v = hex.trim().replace('#', '')
|
||||
if (v.length === 3) v = v[0] + v[0] + v[1] + v[1] + v[2] + v[2]
|
||||
return [
|
||||
parseInt(v.slice(0, 2), 16) / 255,
|
||||
parseInt(v.slice(2, 4), 16) / 255,
|
||||
parseInt(v.slice(4, 6), 16) / 255,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Linien ────────────────────────────────────────────────────────
|
||||
slider('speed', 2, (v) => (fl.uniforms.animationSpeed.value = v))
|
||||
slider('lineCount', 0, (v) => (fl.uniforms.middleLineCount.value = Math.round(v)))
|
||||
slider('spread', 2, (v) => (fl.uniforms.lineSpread.value = v))
|
||||
slider('fanSpread', 2, (v) => (fl.uniforms.fanSpread.value = v))
|
||||
slider('lineSharpness', 1, (v) => (fl.uniforms.lineSharpness.value = v))
|
||||
slider('waveFreq', 1, (v) => (fl.uniforms.waveFrequency.value = v))
|
||||
slider('bezierCurv', 2, (v) => (fl.uniforms.bezierCurvature.value = v))
|
||||
|
||||
slider('glowSize', 1, (v) => (fl.uniforms.circleGlowSize.value = v))
|
||||
slider('glowStrength', 1, (v) => (fl.uniforms.circleGlowStrength.value = v))
|
||||
|
||||
const crInput = document.getElementById('circleRadius')
|
||||
const crVal = document.getElementById('circleRadius-val')
|
||||
crInput.addEventListener('input', () => {
|
||||
const px = parseFloat(crInput.value)
|
||||
crVal.textContent = px + 'px'
|
||||
fl.uniforms.circleRadiusPx.value = px
|
||||
})
|
||||
|
||||
// ── Raster ────────────────────────────────────────────────────────
|
||||
function updateActivePoints(n) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
document.getElementById('pr' + i).classList.toggle('inactive', i >= n)
|
||||
}
|
||||
}
|
||||
|
||||
const numPointsInput = document.getElementById('numPoints')
|
||||
const numPointsVal = document.getElementById('numPoints-val')
|
||||
numPointsInput.addEventListener('input', () => {
|
||||
const n = parseInt(numPointsInput.value)
|
||||
numPointsVal.textContent = n
|
||||
fl.uniforms.numPoints.value = n
|
||||
updateActivePoints(n)
|
||||
})
|
||||
|
||||
slider('spacing', 2, (v) => (fl.uniforms.pointSpacingX.value = v))
|
||||
slider('offsetX', 2, (v) => (fl.uniforms.pointsOffsetX.value = v))
|
||||
|
||||
// ── Punkt-Höhen (Y-Flip) ──────────────────────────────────────────
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const input = document.getElementById('py' + i)
|
||||
const disp = document.getElementById('py' + i + '-val')
|
||||
input.addEventListener('input', () => {
|
||||
const v = parseFloat(input.value)
|
||||
disp.textContent = v.toFixed(2)
|
||||
fl.uniforms.pointY.value[i] = -v // Y-Flip
|
||||
})
|
||||
}
|
||||
|
||||
// ── Hintergrundbild ───────────────────────────────────────────────
|
||||
document.querySelectorAll('.img-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.img-btn').forEach((b) => b.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
const img = btn.dataset.img
|
||||
container.style.backgroundImage = img ? `url('${img}')` : 'none'
|
||||
})
|
||||
})
|
||||
|
||||
// ── Hintergrundverlauf ────────────────────────────────────────────
|
||||
function hexToUniformVec3(hex, uniform) {
|
||||
const [r, g, b] = hexToVec3(hex)
|
||||
uniform.value.set(r, g, b)
|
||||
}
|
||||
document.getElementById('bgCenter').addEventListener('input', (e) => {
|
||||
hexToUniformVec3(e.target.value, fl.uniforms.bgColorCenter)
|
||||
})
|
||||
document.getElementById('bgEdge').addEventListener('input', (e) => {
|
||||
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge)
|
||||
})
|
||||
|
||||
// ── Gradient ──────────────────────────────────────────────────────
|
||||
const MAX_STOPS = 8
|
||||
function applyGradient() {
|
||||
const lines = document
|
||||
.getElementById('gradientInput')
|
||||
.value.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
const stops = lines.slice(0, MAX_STOPS)
|
||||
fl.uniforms.lineGradientCount.value = stops.length
|
||||
stops.forEach((hex, i) => {
|
||||
const [r, g, b] = hexToVec3(hex)
|
||||
fl.uniforms.lineGradient.value[i].set(r, g, b)
|
||||
})
|
||||
}
|
||||
document.getElementById('applyGradient').addEventListener('click', applyGradient)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||