diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 4a8d873..1e8fdeb 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -31,9 +31,6 @@
"WWWGROUP": "20",
"LARAVEL_SAIL": "1"
},
- "mounts": [
- "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
- ],
"forwardPorts": [
5173,
9000
diff --git a/docker-compose.yml b/docker-compose.yml
index ae33b91..0d74f9a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,7 @@ services:
REDIS_HOST: global-redis
volumes:
- './backend:/var/www/html'
+ - '.:/workspace:cached'
networks:
- sail
- proxy
diff --git a/frontend/dev/IMPROVEMENTS-floating-lines.md b/frontend/dev/IMPROVEMENTS-floating-lines.md
new file mode 100644
index 0000000..39b422d
--- /dev/null
+++ b/frontend/dev/IMPROVEMENTS-floating-lines.md
@@ -0,0 +1,200 @@
+# Verbesserungsvorschläge: floating-lines.js
+
+Analyse von `floating-lines.js` (Dev-Klasse) im Vergleich zur produktiven `FloatingLines.vue`.
+**Stand: April 2026 — alle wesentlichen Punkte umgesetzt.**
+
+---
+
+## Was der Code macht (Kurzübersicht)
+
+Ein WebGL-Fullscreen-Shader via Three.js mit drei visuellen Schichten:
+
+- **Top/Bottom**: Einfache Sinus-Wellen mit Rotation (`wave()`)
+- **Middle**: Bézier-Kurven zwischen Kontrollpunkten mit animierten Fächer-Linien (`waveFocal()`) + Kreise an den Punkten
+- **Hintergrund**: Radialer Verlauf von Mitte → Rand (oder Horizont-Split bei Modus „Trennung")
+
+---
+
+## Fehler / Bugs
+
+### 1. Totes Uniform `bgColor` ✅ Umgesetzt
+**Datei:** `floating-lines.js`, Zeile 508
+Das tote `bgColor`-Uniform wurde entfernt. Der Shader nutzt ausschließlich `bgColorCenter` + `bgColorEdge`.
+
+```js
+// Entfernt:
+bgColor: { value: new Vector3(0, 0, 0) },
+```
+
+### 2. Konstruktor-Parameter `middleWavePosition` wird ignoriert ✅ Umgesetzt
+Der ungenutzte Parameter wurde aus der Konstruktorsignatur entfernt.
+
+### 3. Kein Konstruktor-Interface für `bgColorCenter` / `bgColorEdge` ✅ Umgesetzt
+Beide Uniforms sind jetzt als Konstruktor-Parameter verfügbar und werden korrekt initialisiert:
+
+```js
+constructor({ ..., bgColorCenter = '#0a0514', bgColorEdge = '#000000', ... })
+```
+
+Die Uniforms werden beim Konstruktoraufruf aus den Hex-Strings in `Vector3`-Werte konvertiert.
+
+---
+
+## Performance-Problem
+
+### 4. `bezierClosestT` wird pro Linie neu berechnet (kritisch) ✅ Umgesetzt
+`bezierClosestT` wird jetzt **einmal pro Segment** berechnet. Die Ergebnisse (`bt`, `bPos`, `bNorm`) werden als Parameter an `waveFocal()` übergeben:
+
+```glsl
+// Neue waveFocal()-Signatur (precomputed values):
+float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm)
+
+// Im Segment-Loop (einmal pro Segment, nicht pro Linie):
+float bt = bezierClosestT(baseUv, sp, pc, ep);
+vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
+vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
+vec2 bNorm = vec2(-bTang.y, bTang.x);
+// → alle middleLineCount Aufrufe nutzen dieselben Werte
+```
+
+Reduktion von O(Segmente × Linien) auf O(Segmente) `bezierClosestT`-Aufrufe pro Pixel.
+
+---
+
+## Fehlende Features (in `.vue` vorhanden, in `.js` nicht)
+
+### 5. `lineBrightness` Uniform fehlt ✅ Umgesetzt
+`lineBrightness` ist jetzt als Konstruktor-Parameter und Uniform vorhanden. Im Shader:
+
+```glsl
+col *= lineBrightness; // vor Background-Composite
+```
+
+### 6. Kein Pause bei verstecktem Tab ✅ Umgesetzt
+Der `requestAnimationFrame`-Loop pausiert jetzt bei `document.hidden`:
+
+```js
+this._handleVisibility = () => {
+ if (document.hidden) {
+ cancelAnimationFrame(this.raf)
+ this.raf = 0
+ } else if (!this.raf) {
+ renderLoop()
+ }
+}
+document.addEventListener('visibilitychange', this._handleVisibility)
+// destroy() ruft removeEventListener auf
+```
+
+### 7. Kein adaptives DPR ⏭️ Offen / Optional
+Die Vue-Version misst FPS live und reduziert `devicePixelRatio` bei schlechter Performance (vor allem Mobile). Die Dev-Klasse ist ein Test-Tool und nicht für Mobile ausgelegt — diese Komplexität lohnt sich hier nicht.
+
+---
+
+## Code-Qualität
+
+### 8. Legacy-Code: `background_color()`, `BLACK`, `PINK`, `BLUE` ✅ Umgesetzt
+Die Shader-Konstanten `BLACK`, `PINK`, `BLUE` und die Funktion `background_color()` wurden entfernt. Der Hintergrund wird immer über `bgColorCenter`/`bgColorEdge` gesteuert.
+
+### 9. Redundante `enabledWaves.includes()` Checks ✅ Umgesetzt
+Die doppelten Prüfungen in den Hilfsfunktionen wurden entfernt. Die äußere Prüfung im Aufrufer ist die einzige Guard.
+
+### 10. Hardcoded `* 0.5` in `getLineColor()` ✅ Umgesetzt
+Der feste `* 0.5`-Faktor wurde entfernt. `getLineColor()` gibt jetzt die volle Gradient-Farbe zurück. Die Kompensation mit `* 2.5` an den Kreisen wurde auf `* 1.5` angepasst. Die Helligkeit wird über das `lineBrightness`-Uniform gesteuert (→ Punkt 5).
+
+---
+
+## Optionale Verbesserungen / Ideen
+
+### 11. Glättere Kreise bei höherem DPR ⏭️ Offen / Optional
+Der AA-Radius passt sich durch `iResolution` (physische Pixel bei gesetztem DPR) bereits implizit an. Keine Änderung nötig.
+
+### 12. `pointSpacingX` + `pointsOffsetX` vs. explizite X-Koordinaten ⏭️ Offen / Optional
+Die Dev-Klasse behält das Auto-Spacing-Modell für einfache Testzwecke. Die Vue-Komponente nutzt explizite X-Koordinaten für die Lebenszeitlinie. Beide Ansätze sind intentional verschieden.
+
+### 13. GLSL `precision highp` → `mediump` ✅ Umgesetzt
+Fragment-Shader nutzt jetzt `precision mediump float` — ausreichend für diese Visualisierung, effizienter auf Mobile/Low-End.
+
+---
+
+## Neue Punkte (nachträglich ergänzt)
+
+### 14. Resize-Bug: Kreise und Linien desynchronisieren sich ✅ Umgesetzt
+**Betrifft:** `LifeWaveLayout.vue`
+
+**Ursache:** `layoutResizeObserver` aktualisierte `layoutWidth/Height` sofort, während `TimelineView`s `@view-update` (mit den neuen CSS-Event-Positionen) erst im nächsten Frame kam — führte zu 1-Frame UV-Desync.
+
+**Fix:** `requestAnimationFrame`-Wrapper im Callback:
+
+```js
+layoutResizeObserver = new ResizeObserver(() => {
+ requestAnimationFrame(() => {
+ if (!layoutRef.value) return
+ layoutWidth.value = layoutRef.value.clientWidth
+ layoutHeight.value = layoutRef.value.clientHeight
+ })
+})
+```
+
+### 15. Feature: Horizont ✅ Umgesetzt (erweitert)
+Statt einer einzelnen Linie wurden **drei wählbare Horizont-Modi** implementiert:
+
+| Modus | Wert | Beschreibung |
+|-------|------|--------------|
+| Aus | `'off'` | Kein Horizont-Effekt |
+| Nebel | `'fog'` | Leuchtender Band-Effekt auf Y=0, Farbe aus Gradient |
+| Trennung | `'split'` | Hintergrund wird vertikal geteilt: `bgColorCenter` oben, `bgColorEdge` unten — mit einstellbarer Blend-Breite |
+| Glow | `'glow'` | Weiches + hartes Leuchten auf dem Horizont, Farbe aus Gradient |
+
+**Shader (alle Modi):**
+```glsl
+uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
+uniform float horizonOpacity;
+uniform float horizonBlend;
+
+if (horizonMode == 1) {
+ float band = exp(-baseUv.y * baseUv.y * 5.0);
+ vec3 fogColor = getLineColor(0.5, bg) * 2.0;
+ col += fogColor * band * horizonOpacity;
+} else if (horizonMode == 2) {
+ float blendW = max(horizonBlend * 0.7, 0.001);
+ float t = smoothstep(-blendW, blendW, baseUv.y);
+ bg = mix(bgColorEdge, bgColorCenter, t);
+} else if (horizonMode == 3) {
+ float d2 = baseUv.y * baseUv.y;
+ float softGlow = exp(-d2 * 10.0);
+ float coreGlow = exp(-d2 * 70.0) * 0.7;
+ vec3 glowColor = getLineColor(0.5, bg) * 3.0;
+ col += glowColor * (softGlow + coreGlow) * horizonOpacity;
+}
+```
+
+**Umgesetzt in:**
+- `floating-lines.js` — Shader + Konstruktor-Parameter + Uniforms
+- `FloatingLines.vue` — Shader (mit `gradientMid()` Hilfsfunktion) + Props + Uniforms + Watches
+- `settings.js` — `horizonMode: 'off'`, `horizonOpacity: 0.5`, `horizonBlend: 0.2`
+- `LifeWaveSettings.vue` — Segmented Control + bedingte Slider (Deckkraft / Übergang)
+- `LifeWaveLayout.vue` — Props weitergeleitet
+- `init-fl.html` — 4 Modus-Buttons + bedingte Slider-Sichtbarkeit
+
+---
+
+## Zusammenfassung Prioritäten
+
+| # | Typ | Priorität | Aufwand | Status |
+|---|-----|-----------|---------|--------|
+| 4 | Performance: `bezierClosestT` Hoisting | **Hoch** | Mittel | ✅ |
+| 14 | Bug: Resize-Desync (LifeWaveLayout) | **Hoch** | Minimal | ✅ |
+| 1 | Bug: totes `bgColor` Uniform | Mittel | Minimal | ✅ |
+| 3 | Bug: `bgColorCenter/Edge` nicht setzbar | Mittel | Klein | ✅ |
+| 5 | Feature: `lineBrightness` | Mittel | Klein | ✅ |
+| 10 | Qualität: hardcoded `* 0.5` | Mittel | Klein | ✅ |
+| 15 | Feature: Horizont (3 Modi) | Mittel | Mittel | ✅ |
+| 8 | Qualität: Legacy-Code entfernen | Niedrig | Klein | ✅ |
+| 6 | Feature: Tab-Pause | Niedrig | Klein | ✅ |
+| 2 | Bug: ignorierter Parameter | Niedrig | Minimal | ✅ |
+| 9 | Qualität: redundante Checks | Niedrig | Minimal | ✅ |
+| 13 | Perf: `mediump` Precision | Optional | Minimal | ✅ |
+| 7 | Feature: adaptives DPR | Optional | Groß | ⏭️ |
+| 11 | Qualität: AA-Radius explizit | Optional | Minimal | ⏭️ |
+| 12 | API: explizite X-Koordinaten | Optional | Mittel | ⏭️ |
diff --git a/frontend/dev/floating-lines.js b/frontend/dev/floating-lines.js
index 3f81070..f0be204 100644
--- a/frontend/dev/floating-lines.js
+++ b/frontend/dev/floating-lines.js
@@ -7,7 +7,6 @@ import {
ShaderMaterial,
Vector3,
Vector2,
- Clock,
} from 'three'
const vertexShader = `
@@ -19,7 +18,7 @@ void main() {
`
const fragmentShader = `
-precision highp float;
+precision mediump float;
uniform float iTime;
uniform vec3 iResolution;
@@ -58,34 +57,24 @@ uniform float bendRadius;
uniform float bendStrength;
uniform float bendInfluence;
+uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
+uniform float horizonOpacity; // Nebel + Glow: Helligkeit/Dichte
+uniform float horizonBlend; // Trennung: 0=scharf, 1=weicher Übergang
+
uniform bool parallax;
uniform float parallaxStrength;
uniform vec2 parallaxOffset;
+uniform float lineBrightness;
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;
@@ -108,7 +97,7 @@ vec3 getLineColor(float t, vec3 baseColor) {
gradientColor = mix(c1, c2, f);
}
- return gradientColor * 0.5;
+ return gradientColor;
}
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
@@ -163,25 +152,9 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
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);
+// Accepts precomputed bezier values (bt, bPos, bNorm) — computed once per segment
+float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 bPos, vec2 bNorm) {
+ float s = dot(uv - bPos, bNorm);
float time = iTime * animationSpeed;
float normalizedI = totalLines > 1.0 ? fi / (totalLines - 1.0) : 0.5;
@@ -227,7 +200,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 col = vec3(0.0);
- vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv);
+ vec3 b = bgColorCenter;
vec2 mouseUv = vec2(0.0);
if (interactive) {
@@ -269,33 +242,34 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
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);
+ // Segment-Geometrie (einmalig berechnet, von Gradient + Bézier genutzt)
+ vec2 seg = ep - sp;
+ float segL = length(seg);
+ vec2 segDir = segL > 0.001 ? seg / segL : vec2(1.0, 0.0);
+ vec2 sPerp = vec2(-segDir.y, segDir.x);
+ vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
+
+ // Gradient
+ float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
float t_global = (float(s) + t_seg) * tScale;
- vec3 lineCol = getLineColor(t_global, b);
+ 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;
+ // Bézier einmal pro Segment — geteilt von Nebel + allen Linien
+ 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;
+ vec2 bTang = normalize(2.0*bmt*(pc - sp) + 2.0*bt*(ep - pc));
+ vec2 bNorm = vec2(-bTang.y, bTang.x);
- // 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);
+ // Weicher Nebel entlang der Kurve
+ 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);
+ col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
}
}
@@ -304,7 +278,7 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
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;
+ vec3 circCol = getLineColor(t_pt, b) * 1.5;
col += drawCircle(baseUv, vec2(px, pointY[p]), r, circCol);
}
}
@@ -328,9 +302,33 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
}
}
+ col *= lineBrightness;
+
// 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));
+
+ if (horizonMode == 1) {
+ // Nebel: breites weiches Gaussband in Gradient-Mittelfarbe
+ float band = exp(-baseUv.y * baseUv.y * 5.0);
+ vec3 fogColor = getLineColor(0.5, bg) * 2.0;
+ col += fogColor * band * horizonOpacity;
+ } else if (horizonMode == 2) {
+ // Farbtrennung: vertikaler Split an Y=0
+ // bgColorCenter → oben (positives UV-Y), bgColorEdge → unten
+ // horizonBlend: 0=harter Schnitt, 1=sehr weicher Übergang
+ float blendW = max(horizonBlend * 0.7, 0.001);
+ float t = smoothstep(-blendW, blendW, baseUv.y);
+ bg = mix(bgColorEdge, bgColorCenter, t);
+ } else if (horizonMode == 3) {
+ // Glow: konzentriertes Leuchten in Gradient-Mittelfarbe
+ float d2 = baseUv.y * baseUv.y;
+ float softGlow = exp(-d2 * 10.0);
+ float coreGlow = exp(-d2 * 70.0) * 0.7;
+ vec3 glowColor = getLineColor(0.5, bg) * 3.0;
+ col += glowColor * (softGlow + coreGlow) * horizonOpacity;
+ }
+
fragColor = vec4(clamp(bg + col, 0.0, 1.0), 1.0);
}
@@ -376,7 +374,6 @@ export default class FloatingLines {
lineCount = [6],
lineDistance = [5],
topWavePosition,
- middleWavePosition,
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
numPoints = 4,
pointSpacingX = 0.8,
@@ -389,6 +386,7 @@ export default class FloatingLines {
bezierCurvature = 0.3,
circleRadiusPx = 50,
animationSpeed = 1,
+ lineBrightness = 1.0,
interactive = true,
bendRadius = 5.0,
bendStrength = -0.5,
@@ -397,6 +395,11 @@ export default class FloatingLines {
parallaxStrength = 0.2,
circleGlowSize = 18.0,
circleGlowStrength = 1.5,
+ horizonMode = 'off',
+ horizonOpacity = 0.5,
+ horizonBlend = 0.2,
+ bgColorCenter = '#0a0514',
+ bgColorEdge = '#000000',
mixBlendMode = 'screen',
} = {},
) {
@@ -427,14 +430,12 @@ export default class FloatingLines {
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 topLineCount = getLineCount('top')
+ const middleLineCount = getLineCount('middle')
+ const bottomLineCount = getLineCount('bottom')
- const topLineDistance = enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01
- const bottomLineDistance = enabledWaves.includes('bottom')
- ? getLineDistance('bottom') * 0.01
- : 0.01
+ const topLineDistance = getLineDistance('top') * 0.01
+ const bottomLineDistance = getLineDistance('bottom') * 0.01
this.scene = new Scene()
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
@@ -451,6 +452,7 @@ export default class FloatingLines {
iTime: { value: 0 },
iResolution: { value: new Vector3(1, 1, 1) },
animationSpeed: { value: animationSpeed },
+ lineBrightness: { value: lineBrightness },
enableTop: { value: enabledWaves.includes('top') },
enableMiddle: { value: enabledWaves.includes('middle') },
@@ -497,6 +499,10 @@ export default class FloatingLines {
bendStrength: { value: bendStrength },
bendInfluence: { value: 0 },
+ horizonMode: { value: { off: 0, fog: 1, split: 2, glow: 3 }[horizonMode] ?? 0 },
+ horizonOpacity: { value: horizonOpacity },
+ horizonBlend: { value: horizonBlend },
+
parallax: { value: parallax },
parallaxStrength: { value: parallaxStrength },
parallaxOffset: { value: new Vector2(0, 0) },
@@ -505,7 +511,8 @@ export default class FloatingLines {
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
},
lineGradientCount: { value: 0 },
- bgColor: { value: new Vector3(0, 0, 0) },
+ bgColorCenter: { value: new Vector3(0, 0, 0) },
+ bgColorEdge: { value: new Vector3(0, 0, 0) },
}
if (linesGradient && linesGradient.length > 0) {
@@ -517,6 +524,11 @@ export default class FloatingLines {
})
}
+ const center = hexToVec3(bgColorCenter)
+ this.uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
+ const edge = hexToVec3(bgColorEdge)
+ this.uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
+
const material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader,
@@ -529,7 +541,7 @@ export default class FloatingLines {
this.geometry = geometry
this.material = material
- this.clock = new Clock()
+ this._startTime = performance.now()
this._setSize = () => {
const width = container.clientWidth || 1
@@ -571,7 +583,7 @@ export default class FloatingLines {
this.raf = 0
const renderLoop = () => {
- this.uniforms.iTime.value = this.clock.getElapsedTime()
+ this.uniforms.iTime.value = (performance.now() - this._startTime) * 0.001
if (this.interactive) {
this.currentMouse.lerp(this.targetMouse, this.mouseDamping)
@@ -589,12 +601,24 @@ export default class FloatingLines {
this.renderer.render(this.scene, this.camera)
this.raf = requestAnimationFrame(renderLoop)
}
+
+ this._handleVisibility = () => {
+ if (document.hidden) {
+ cancelAnimationFrame(this.raf)
+ this.raf = 0
+ } else if (!this.raf) {
+ renderLoop()
+ }
+ }
+ document.addEventListener('visibilitychange', this._handleVisibility)
+
renderLoop()
}
destroy() {
cancelAnimationFrame(this.raf)
if (this.ro) this.ro.disconnect()
+ document.removeEventListener('visibilitychange', this._handleVisibility)
this.renderer.domElement.removeEventListener('pointermove', this._handlePointerMove)
this.renderer.domElement.removeEventListener('pointerleave', this._handlePointerLeave)
diff --git a/frontend/dev/init-fl.html b/frontend/dev/init-fl.html
index c253d8f..ba0171a 100644
--- a/frontend/dev/init-fl.html
+++ b/frontend/dev/init-fl.html
@@ -50,7 +50,7 @@
border-top: 1px solid #222;
padding: 10px 14px;
display: grid;
- grid-template-columns: 1fr 1fr 2fr 1.2fr;
+ grid-template-columns: 1fr 1fr 2fr 0.8fr 1.2fr;
gap: 10px 16px;
max-height: 230px;
overflow-y: auto;
@@ -287,6 +287,27 @@
+
+
+
Horizont
+
+
+
+
+
+
+
+
+
+ 0.50
+
+
+
+
+ 0.20
+
+
+
Hintergrundbild
@@ -501,6 +522,27 @@
hexToUniformVec3(e.target.value, fl.uniforms.bgColorEdge)
})
+ // ── Horizont ─────────────────────────────────────────────────────
+ const rowOpacity = document.getElementById('row-horizonOpacity')
+ const rowBlend = document.getElementById('row-horizonBlend')
+
+ function updateHorizonRows(mode) {
+ rowOpacity.style.display = mode === 2 ? 'none' : 'flex'
+ rowBlend.style.display = mode === 2 ? 'flex' : 'none'
+ }
+
+ document.querySelectorAll('[data-mode]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('[data-mode]').forEach((b) => b.classList.remove('active'))
+ btn.classList.add('active')
+ const mode = parseInt(btn.dataset.mode)
+ fl.uniforms.horizonMode.value = mode
+ updateHorizonRows(mode)
+ })
+ })
+ slider('horizonOpacity', 2, (v) => (fl.uniforms.horizonOpacity.value = v))
+ slider('horizonBlend', 2, (v) => (fl.uniforms.horizonBlend.value = v))
+
// ── Gradient ──────────────────────────────────────────────────────
const MAX_STOPS = 8
function applyGradient() {
diff --git a/frontend/src/components/AppSettingsModal.vue b/frontend/src/components/AppSettingsModal.vue
index 09ab13b..e4443b6 100644
--- a/frontend/src/components/AppSettingsModal.vue
+++ b/frontend/src/components/AppSettingsModal.vue
@@ -74,6 +74,44 @@
+
+
+
Emotionsverlauf
+
+
+
+
+ Start
+ { settingsStore.emotionGradientStart = e.target.value }"
+ class="settings-color-input"
+ />
+
+
+
+ Ende
+ { settingsStore.emotionGradientEnd = e.target.value }"
+ class="settings-color-input"
+ />
+
+
+
+
+
+
Sprache
@@ -100,7 +138,7 @@
:model-value="settingsStore.showFps"
@update:model-value="v => { settingsStore.showFps = v }"
dense
- color="green"
+ color="primary"
/>
@@ -146,14 +184,18 @@ const tabs = [
const currentAccentHex = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
- return found?.hex ?? '#9e9e9e'
+ return found?.hex ?? '#737373'
})
const currentAccentLabel = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
- return found?.label ?? 'Standard'
+ return found?.label ?? 'Base'
})
+const emotionGradientCss = computed(() =>
+ `linear-gradient(90deg, ${settingsStore.emotionGradientStart} 0%, ${settingsStore.emotionGradientEnd} 100%)`
+)
+
function selectAccent(value) {
settingsStore.accentColor = value
accentDropdownOpen.value = false
@@ -198,6 +240,12 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
min-height: 40px;
}
+.settings-row--stack {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 10px;
+}
+
.settings-row__label {
font-size: 15px;
}
@@ -242,6 +290,7 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
+ border-color: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.35);
}
.settings-accent-btn--dark {
@@ -294,12 +343,53 @@ watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
}
.settings-dropdown__item:hover {
- background: rgba(128, 128, 128, 0.1);
+ background: rgba(var(--tm-accent-rgb, 115, 115, 115), 0.12);
}
.settings-dropdown__check {
margin-left: auto;
opacity: 0.7;
+ color: var(--tm-accent, #737373);
+}
+
+.settings-gradient {
+ width: 100%;
+}
+
+.settings-gradient__preview {
+ height: 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(128, 128, 128, 0.2);
+ margin-bottom: 10px;
+}
+
+.settings-gradient__controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.settings-gradient__control {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ opacity: 0.7;
+}
+
+.settings-gradient__control--right {
+ justify-content: flex-end;
+}
+
+.settings-color-input {
+ width: 34px;
+ height: 22px;
+ border: none;
+ background: transparent;
+ padding: 0;
+ cursor: pointer;
+ border-radius: 6px;
}
/* Placeholder text */
diff --git a/frontend/src/components/EventPanel.vue b/frontend/src/components/EventPanel.vue
index ea0d2de..5f57d38 100644
--- a/frontend/src/components/EventPanel.vue
+++ b/frontend/src/components/EventPanel.vue
@@ -20,12 +20,43 @@
![]()
-
Key Image
+
+
+
+
+
+
-
@@ -38,22 +69,42 @@
:dark="isDark"
/>
-
-
-
-
{{ formattedDate }}
-
-
+
+ Datum
+
+
+
+
+
+
+
+ Ort
+
-
+ >
+
+
+
+
+
-
+
-
-
-
Farbverlauf
-
-
-
-
-
-
+
+
+ Punktfarbe (optional)
+ { eventsStore.ghostCustomColor = e.target.value }"
+ class="event-panel__color-input"
+ />
+
+
+
@@ -129,10 +178,41 @@
Weitere Medien
@@ -149,6 +229,60 @@
/>
+
+
+
+
+ Key Image entfernen?
+
+ Das Key Image wird endgültig aus diesem Event entfernt.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -156,12 +290,14 @@
@@ -311,8 +572,12 @@ function onAddMedia() {
position: absolute;
top: 12px;
left: 12px;
+ max-width: min(180px, calc(100% - 24px));
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ outline: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
+ font-family: inherit;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
@@ -320,6 +585,56 @@ function onAddMedia() {
backdrop-filter: blur(4px);
}
+.event-panel__image-badge::placeholder {
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.event-panel__image-actions {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ display: flex;
+ gap: 8px;
+}
+
+.event-panel__image-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 999px;
+ padding: 6px 10px;
+ background: rgba(0, 0, 0, 0.48);
+ color: #fff;
+ font: inherit;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
+}
+
+.event-panel__image-gallery {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 36px;
+ height: 36px;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: rgba(0, 0, 0, 0.48);
+ color: #fff;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
+}
+
+.event-panel__image-action--danger {
+ background: rgba(150, 35, 35, 0.62);
+}
+
.event-panel__image-placeholder {
display: flex;
flex-direction: column;
@@ -339,6 +654,34 @@ function onAddMedia() {
opacity: 0.8;
}
+.event-panel__file-input {
+ display: none;
+}
+
+.event-panel__confirm {
+ width: min(360px, calc(100vw - 32px));
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.96);
+ color: #1a1a1a;
+}
+
+.event-panel__confirm--dark {
+ background: rgba(30, 30, 30, 0.96);
+ color: #f5f5f5;
+}
+
+.event-panel__confirm-title {
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.event-panel__confirm-copy {
+ margin-top: 6px;
+ font-size: 13px;
+ line-height: 1.4;
+ opacity: 0.72;
+}
+
/* Title */
.event-panel__title {
margin-bottom: 0;
@@ -350,27 +693,65 @@ function onAddMedia() {
line-height: 1.3;
}
-/* Date row */
-.event-panel__date-row {
+.event-panel__meta-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin: 6px 0 12px;
+}
+
+.event-panel__meta-item {
+ background: rgba(128, 128, 128, 0.08);
+ border: 1px solid rgba(128, 128, 128, 0.14);
+ border-radius: 10px;
+ padding: 8px 10px;
+ min-height: 56px;
+}
+
+.event-panel__meta-label {
+ display: block;
+ font-size: 11px;
+ font-weight: 600;
+ opacity: 0.55;
+ margin-bottom: 4px;
+}
+
+.event-panel__date-btn {
+ width: 100%;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font-family: inherit;
+ padding: 0;
display: flex;
align-items: center;
gap: 6px;
- margin-bottom: 20px;
- opacity: 0.6;
+ text-align: left;
cursor: pointer;
- transition: opacity 0.2s;
-}
-
-.event-panel__date-row:hover {
- opacity: 0.9;
}
.event-panel__date-icon {
- flex-shrink: 0;
+ opacity: 0.7;
}
.event-panel__date-label {
- font-size: 14px;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.event-panel__location-input {
+ margin-top: -2px;
+}
+
+.event-panel__location-input :deep(.q-field__prepend) {
+ margin-right: 4px;
+ color: inherit;
+ opacity: 0.65;
+}
+
+.event-panel__location-input :deep(.q-field__native) {
+ font-size: 13px;
+ font-weight: 500;
}
/* Card sections */
@@ -447,51 +828,39 @@ function onAddMedia() {
margin-top: 4px;
}
-/* Gradient Preset Selector */
-.event-panel__presets {
+.event-panel__gradient-edit {
margin-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
padding-top: 12px;
}
+.event-panel__gradient-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
.event-panel__presets-label {
font-size: 11px;
font-weight: 600;
opacity: 0.5;
- display: block;
- margin-bottom: 8px;
}
-.event-panel__presets-grid {
- display: flex;
- gap: 6px;
- flex-wrap: wrap;
-}
-
-.event-panel__preset {
- width: 45px;
- height: 25px;
+.event-panel__color-input {
+ width: 44px;
+ height: 24px;
+ border: none;
border-radius: 6px;
+ padding: 0;
+ background: transparent;
cursor: pointer;
- border: 2px solid #eee;
- transition: border-color 0.2s, transform 0.15s;
}
-.event-panel__preset:hover {
- transform: scale(1.1);
-}
-
-.event-panel__preset--active {
- border-color: currentColor;
- transform: scale(1.1);
- box-shadow: 0 0 0 1px rgba(128, 128, 128, 0.3);
-}
-
-.event-panel__preset--none {
- background: rgba(128, 128, 128, 0.15);
+.event-panel__gradient-actions {
display: flex;
- align-items: center;
- justify-content: center;
+ justify-content: flex-end;
}
/* Note */
@@ -502,15 +871,66 @@ function onAddMedia() {
/* Media grid */
.event-panel__media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.event-panel__media-item {
+ position: relative;
+ aspect-ratio: 1;
+ border-radius: 10px;
+ overflow: hidden;
+ background: rgba(128, 128, 128, 0.12);
+}
+
+.event-panel__media-img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: cover;
+}
+
+.event-panel__media-remove {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 30px;
+ height: 30px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 50%;
display: flex;
- gap: 8px;
- margin-top: 8px;
- flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: rgba(0, 0, 0, 0.58);
+ color: #fff;
+ cursor: pointer;
+ backdrop-filter: blur(4px);
+}
+
+.event-panel__media-open {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 30px;
+ height: 30px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: rgba(0, 0, 0, 0.58);
+ color: #fff;
+ cursor: pointer;
+ backdrop-filter: blur(4px);
}
.event-panel__media-add {
- width: 64px;
- height: 64px;
+ min-height: 128px;
+ aspect-ratio: 1;
border-radius: 10px;
border: 2px dashed rgba(128, 128, 128, 0.2);
display: flex;
@@ -520,10 +940,72 @@ function onAddMedia() {
transition: border-color 0.2s;
}
+@media (max-width: 380px) {
+ .event-panel__media-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .event-panel__media-add {
+ min-height: 112px;
+ }
+}
+
+@media (min-width: 640px) {
+ .event-panel__media-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
.event-panel__media-add:hover {
border-color: rgba(128, 128, 128, 0.4);
}
+.event-panel__gallery {
+ position: fixed;
+ inset: 0;
+ background: #000;
+ color: #fff;
+}
+
+.event-panel__gallery-close {
+ position: fixed;
+ top: max(16px, env(safe-area-inset-top));
+ right: 16px;
+ z-index: 2;
+ width: 44px;
+ height: 44px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: rgba(0, 0, 0, 0.48);
+ color: #fff;
+ cursor: pointer;
+ backdrop-filter: blur(8px);
+}
+
+.event-panel__gallery-carousel {
+ width: 100%;
+ height: 100dvh;
+ background: #000;
+}
+
+.event-panel__gallery-slide {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.event-panel__gallery-img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
/* Delete */
.event-panel__delete {
display: flex;
diff --git a/frontend/src/components/FloatingLines.vue b/frontend/src/components/FloatingLines.vue
index 054bdd3..e0a00ff 100644
--- a/frontend/src/components/FloatingLines.vue
+++ b/frontend/src/components/FloatingLines.vue
@@ -29,6 +29,7 @@ const props = defineProps({
lineSpread: { type: Number, default: 0.05 },
fanSpread: { type: Number, default: 0.05 },
lineSharpness: { type: Number, default: 8.0 },
+ lineThickness: { type: Number, default: 1.0 },
waveFrequency: { type: Number, default: 7.0 },
bezierCurvature: { type: Number, default: 0.2 },
circleRadiusPx: { type: Number, default: 75 },
@@ -43,7 +44,13 @@ const props = defineProps({
bgColorEdge: { type: String, default: '#000000' },
backgroundImage: { type: String, default: '' },
mixBlendMode: { type: String, default: 'screen' },
- parallax: { type: Boolean, default: false }
+ parallax: { type: Boolean, default: false },
+ horizonMode: { type: String, default: 'off' },
+ horizonOpacity: { type: Number, default: 0.5 },
+ horizonBlend: { type: Number, default: 0.2 },
+ lineMode: { type: String, default: 'glow' }, // 'glow' | 'static'
+ staticLineColor: { type: String, default: '#2196F3' },
+ staticLineShadowStrength: { type: Number, default: 0 }
})
// FPS display
@@ -71,7 +78,7 @@ void main() {
`
const fragmentShader = `
-precision mediump float;
+precision highp float;
uniform float iTime;
uniform vec3 iResolution;
@@ -84,18 +91,36 @@ uniform float pointY[16];
uniform float lineSpread;
uniform float fanSpread;
uniform float lineSharpness;
+uniform float lineThickness;
uniform float waveFrequency;
uniform float bezierCurvature;
uniform float lineBrightness;
uniform vec3 pointColor[16];
+uniform int horizonMode; // 0=off 1=fog 2=split 3=glow
+uniform float horizonOpacity;
+uniform float horizonBlend;
+
+uniform int lineMode; // 0=glow 1=static
+uniform vec3 staticLineColor;
+uniform float staticLineShadowStrength;
+
uniform bool parallax;
uniform vec2 parallaxOffset;
uniform vec3 lineGradient[8];
-uniform int lineGradientCount;
+uniform int lineGradientCount;
uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge;
+uniform int hasBackgroundImage;
+
+vec3 gradientMid() {
+ if (lineGradientCount <= 0) return vec3(0.45, 0.5, 0.8);
+ if (lineGradientCount == 1) return lineGradient[0];
+ float t = 0.4999 * float(lineGradientCount - 1);
+ int idx = int(t);
+ return mix(lineGradient[idx], lineGradient[min(idx + 1, lineGradientCount - 1)], fract(t));
+}
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
float bestT = 0.0;
@@ -126,7 +151,6 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
return t;
}
-// Accepts precomputed bezier values (t, curvePos, norm) — computed once per segment
float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
float s = dot(uv - curvePos, norm);
@@ -139,10 +163,34 @@ float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec
float waveDisp = sin(t * waveFrequency + fi * 1.3 + time * 0.4) * amp
* sin(fi * 0.9 + time * 0.18);
+ float widthScale = max(lineThickness, 0.1);
float dist = s - linePos - waveDisp;
+ float scaledDist = abs(dist) * lineSharpness / widthScale;
+ float fade = smoothstep(-0.06, 0.04, t) * smoothstep(1.06, 0.96, t);
+ float tailMask = smoothstep(1.35, 0.02, scaledDist);
+ float core = 0.013 / max(scaledDist + 0.004, 1e-4);
+ float aura = 0.003 * widthScale * smoothstep(0.42, 0.06, scaledDist);
+
+ return fade * tailMask * core + fade * aura;
+}
+
+float staticLineFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
+ 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 widthScale = max(lineThickness, 0.1);
+ float dist = abs(s - linePos - waveDisp) * lineSharpness / widthScale;
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);
+ return fade * smoothstep(0.022, 0.012, dist);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
@@ -153,9 +201,9 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
baseUv += parallaxOffset;
}
- vec3 col = vec3(0.0);
+ vec3 col = vec3(0.0);
+ float totalIntensity = 0.0;
- const int MAX_PTS = 16;
const int MAX_SEGS = 15;
for (int s = 0; s < MAX_SEGS; ++s) {
@@ -166,36 +214,105 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 segD = ep - sp;
float segL = length(segD);
- vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
+ if (segL < 1e-4) continue;
+ vec2 segDir = segD / segL;
vec2 sPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
- float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
- vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
+ float t_seg = clamp(dot(baseUv - sp, segDir) / max(segL, 1e-4), 0.0, 1.0);
+ vec3 lineCol = lineMode == 1
+ ? staticLineColor
+ : mix(pointColor[s], pointColor[s + 1], t_seg);
- // bezierClosestT computed ONCE per segment — shared by fog + all lines
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;
- vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc));
+ vec2 bTangRaw = bmt*(pc - sp) + bt*(ep - pc);
+ float bTangLen = length(bTangRaw);
+ vec2 bTang = bTangLen > 1e-4 ? bTangRaw / bTangLen : segDir;
vec2 bNorm = vec2(-bTang.y, bTang.x);
- 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;
+ float shadowStrength = clamp(staticLineShadowStrength, 0.0, 1.0);
+ if (lineMode != 1 || shadowStrength > 0.0) {
+ 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 fogVal = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
+ float fogScale = lineMode == 1 ? shadowStrength : 1.0;
+ col += lineCol * fogVal * fogScale;
+ totalIntensity += fogVal * fogScale;
+ }
for (int i = 0; i < middleLineCount; ++i) {
- col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
+ float glowFv = waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
+ float fv = glowFv;
+ if (lineMode == 1) {
+ float crispFv = staticLineFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
+ fv = mix(crispFv, glowFv, shadowStrength);
+ }
+ col += lineCol * fv;
+ totalIntensity += fv;
}
}
- col *= lineBrightness;
+ col *= lineBrightness;
+ totalIntensity *= lineBrightness;
+ if (lineMode != 1) {
+ // Drop only the distant low-energy glow tails that accumulate into cloudy artifacts.
+ float glowPeak = max(col.r, max(col.g, col.b));
+ float cleanupMask = smoothstep(0.018, 0.055, glowPeak);
+ col *= cleanupMask;
+ totalIntensity *= cleanupMask;
+ }
+
+ // Radial background — shared by both modes
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);
+
+ // "Split" horizon changes the background orientation (vertical) in both modes.
+ if (horizonMode == 2) {
+ float blendW = max(horizonBlend * 0.7, 0.001);
+ float t = smoothstep(-blendW, blendW, baseUv.y);
+ bg = mix(bgColorEdge, bgColorCenter, t);
+ }
+
+ if (lineMode == 1) {
+ // Static Lines: radial bg + lines mixed by intensity (no additive glow)
+ float staticMix = clamp(totalIntensity, 0.0, 1.0);
+ vec3 staticCol = mix(bg, staticLineColor, staticMix);
+ if (hasBackgroundImage == 1) {
+ // Keep background image visible by outputting line-only color with alpha.
+ float alpha = staticMix < 0.03 ? 0.0 : staticMix;
+ fragColor = vec4(staticLineColor * alpha, alpha);
+ return;
+ }
+ fragColor = vec4(staticCol, 1.0);
+ return;
+ }
+
+ // Glowing Lines: additive line colors + horizon effects
+
+ if (horizonMode == 1) {
+ float band = exp(-baseUv.y * baseUv.y * 5.0);
+ col += gradientMid() * band * horizonOpacity;
+ } else if (horizonMode == 3) {
+ float d2 = baseUv.y * baseUv.y;
+ float softGlow = exp(-d2 * 10.0);
+ float coreGlow = exp(-d2 * 70.0) * 0.7;
+ col += gradientMid() * (softGlow + coreGlow) * horizonOpacity;
+ }
+
+ vec3 composed = clamp(bg + col, 0.0, 1.0);
+ if (hasBackgroundImage == 1) {
+ // Render only glowing lines above CSS background image.
+ vec3 lineOnly = clamp(col, 0.0, 1.0);
+ float alpha = clamp(max(lineOnly.r, max(lineOnly.g, lineOnly.b)), 0.0, 1.0);
+ alpha = alpha < 0.03 ? 0.0 : alpha;
+ fragColor = vec4(lineOnly * alpha, alpha);
+ return;
+ }
+ fragColor = vec4(composed, 1.0);
}
void main() {
@@ -238,8 +355,14 @@ let rafId = null
let resizeObserver = null
let uniforms = null
let scrollHandler = null
+let boundScrollContainer = null
+let cachedScrollLeft = 0
let scrollIdleTimer = null
let visibilityHandler = null
+// Exposed hook so parent can trigger a hard canvas resize sync.
+let forceResizeHandler = null
+let pointerMoveHandler = null
+let pointerTargetEl = null
// Parallax tracking
let targetParallax = null
@@ -282,6 +405,11 @@ function applyBgColors() {
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
}
+function applyBackgroundImageFlag() {
+ if (!uniforms) return
+ uniforms.hasBackgroundImage.value = props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0
+}
+
// Watch all props for live updates
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
watch(() => props.lineCount, () => {
@@ -291,6 +419,7 @@ watch(() => props.lineCount, () => {
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
+watch(() => props.lineThickness, (v) => { if (uniforms) uniforms.lineThickness.value = v })
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
@@ -311,6 +440,33 @@ watch(() => props.pointColors, applyPointColors, { deep: true })
watch(() => props.linesGradient, applyGradient, { deep: true })
watch(() => props.bgColorCenter, applyBgColors)
watch(() => props.bgColorEdge, applyBgColors)
+watch(() => props.backgroundImage, applyBackgroundImageFlag)
+watch(() => props.horizonMode, (v) => {
+ if (uniforms) uniforms.horizonMode.value = ({ off: 0, fog: 1, split: 2, glow: 3 })[v] ?? 0
+})
+watch(() => props.horizonOpacity, (v) => { if (uniforms) uniforms.horizonOpacity.value = v })
+watch(() => props.horizonBlend, (v) => { if (uniforms) uniforms.horizonBlend.value = v })
+watch(() => props.lineMode, (v) => {
+ if (uniforms) uniforms.lineMode.value = v === 'static' ? 1 : 0
+})
+watch(() => props.staticLineColor, (v) => {
+ if (uniforms) { const c = hexToVec3(v); uniforms.staticLineColor.value.set(c.x, c.y, c.z) }
+})
+watch(() => props.staticLineShadowStrength, (v) => {
+ if (uniforms) uniforms.staticLineShadowStrength.value = v
+})
+
+watch(() => props.scrollContainer, (nextEl, prevEl) => {
+ if (prevEl && scrollHandler) {
+ prevEl.removeEventListener('scroll', scrollHandler)
+ if (boundScrollContainer === prevEl) boundScrollContainer = null
+ }
+ if (nextEl && scrollHandler) {
+ cachedScrollLeft = nextEl.scrollLeft || 0
+ nextEl.addEventListener('scroll', scrollHandler, { passive: true })
+ boundScrollContainer = nextEl
+ }
+})
onMounted(() => {
if (!containerRef.value) return
@@ -331,8 +487,9 @@ onMounted(() => {
let currentDpr = DPR_IDLE
let scrolling = false
- renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' })
+ renderer = new WebGLRenderer({ antialias: !isMobile, alpha: true, powerPreference: 'high-performance' })
renderer.setPixelRatio(currentDpr)
+ renderer.setClearAlpha(0)
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.display = 'block'
@@ -357,6 +514,7 @@ onMounted(() => {
lineSpread: { value: props.lineSpread },
fanSpread: { value: props.fanSpread },
lineSharpness: { value: props.lineSharpness },
+ lineThickness: { value: props.lineThickness },
waveFrequency: { value: props.waveFrequency },
bezierCurvature: { value: props.bezierCurvature },
lineBrightness: { value: props.lineBrightness },
@@ -364,6 +522,14 @@ onMounted(() => {
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
},
+ horizonMode: { value: ({ off: 0, fog: 1, split: 2, glow: 3 })[props.horizonMode] ?? 0 },
+ horizonOpacity: { value: props.horizonOpacity },
+ horizonBlend: { value: props.horizonBlend },
+
+ lineMode: { value: props.lineMode === 'static' ? 1 : 0 },
+ staticLineColor: { value: hexToVec3(props.staticLineColor) },
+ staticLineShadowStrength: { value: props.staticLineShadowStrength },
+
parallax: { value: props.parallax },
parallaxOffset: { value: new Vector2(0, 0) },
@@ -372,12 +538,14 @@ onMounted(() => {
},
lineGradientCount: { value: 0 },
bgColorCenter: { value: new Vector3(0, 0, 0) },
- bgColorEdge: { value: new Vector3(0, 0, 0) }
+ bgColorEdge: { value: new Vector3(0, 0, 0) },
+ hasBackgroundImage: { value: props.backgroundImage && props.backgroundImage.trim().length > 0 ? 1 : 0 }
}
// Apply initial values
applyGradient()
applyBgColors()
+ applyBackgroundImageFlag()
applyPointColors()
material = new ShaderMaterial({
@@ -402,6 +570,7 @@ onMounted(() => {
const canvasHeight = renderer.domElement.height
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
}
+ forceResizeHandler = setSize
setSize()
resizeObserver = new ResizeObserver(setSize)
@@ -409,7 +578,7 @@ onMounted(() => {
// Pointer events (parallax only)
if (props.parallax) {
- const handlePointerMove = (event) => {
+ pointerMoveHandler = (event) => {
const rect = renderer.domElement.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
@@ -420,11 +589,11 @@ onMounted(() => {
(-(y - centerY) / rect.height) * 0.2
)
}
- renderer.domElement.addEventListener('pointermove', handlePointerMove)
+ pointerTargetEl = renderer.domElement
+ pointerTargetEl.addEventListener('pointermove', pointerMoveHandler)
}
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
- let cachedScrollLeft = 0
function setDpr(dpr) {
if (dpr === currentDpr) return
@@ -458,6 +627,7 @@ onMounted(() => {
if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
+ boundScrollContainer = props.scrollContainer
}
// Fast inline scroll sync — reads cached scrollLeft instead of DOM during render
@@ -519,8 +689,8 @@ onMounted(() => {
dprDisplay.value = currentDpr.toFixed(2)
}
- // Read latest scrollLeft from DOM in case scroll event was missed
- if (props.scrollContainer) {
+ // Fallback read only when no listener is bound.
+ if (!boundScrollContainer && props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
}
@@ -539,15 +709,23 @@ onMounted(() => {
renderLoop()
})
-defineExpose({ fpsDisplay, dprDisplay })
+function forceResize() {
+ if (forceResizeHandler) forceResizeHandler()
+}
+
+defineExpose({ fpsDisplay, dprDisplay, forceResize })
onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId)
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
if (resizeObserver) resizeObserver.disconnect()
- if (props.scrollContainer && scrollHandler) {
- props.scrollContainer.removeEventListener('scroll', scrollHandler)
+ if (boundScrollContainer && scrollHandler) {
+ boundScrollContainer.removeEventListener('scroll', scrollHandler)
}
+ boundScrollContainer = null
+ if (pointerTargetEl && pointerMoveHandler) pointerTargetEl.removeEventListener('pointermove', pointerMoveHandler)
+ pointerTargetEl = null
+ pointerMoveHandler = null
clearTimeout(scrollIdleTimer)
if (geometry) geometry.dispose()
if (material) material.dispose()
@@ -557,6 +735,7 @@ onBeforeUnmount(() => {
renderer.domElement.parentNode.removeChild(renderer.domElement)
}
}
+ forceResizeHandler = null
})
diff --git a/frontend/src/components/GlowDot.vue b/frontend/src/components/GlowDot.vue
index 627de28..b5a154f 100644
--- a/frontend/src/components/GlowDot.vue
+++ b/frontend/src/components/GlowDot.vue
@@ -8,9 +8,14 @@
'glow-dot--label-above': labelAbove
}"
:style="dotStyle"
+ :role="isGhost ? undefined : 'button'"
+ :tabindex="isGhost ? -1 : 0"
+ :aria-label="dotAriaLabel"
@click.stop="onSelect"
+ @keydown.enter.prevent.stop="onSelect"
+ @keydown.space.prevent.stop="onSelect"
>
-
+
+
{{ event.title }}
{{ formattedDate }}
@@ -43,9 +49,9 @@ const eventsStore = useEventsStore()
const settingsStore = useSettingsStore()
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
-const { resolvedSrc: imageSrc } = props.event.image
- ? useImageCache(props.event.image, props.event.id)
- : { resolvedSrc: computed(() => null) }
+const imageUrl = computed(() => props.event.image || null)
+const eventId = computed(() => props.event.id)
+const { resolvedSrc: imageSrc } = useImageCache(imageUrl, eventId)
const fl = computed(() => settingsStore.floatingLines)
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
@@ -67,19 +73,42 @@ const formattedDate = computed(() => {
const d = new Date(props.event.date)
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
})
+const dotAriaLabel = computed(() => {
+ if (props.isGhost) return undefined
+ const title = props.event.title || 'Event'
+ return `${title}, ${formattedDate.value}`
+})
// Label font sizes per setting
-const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } }
+const LABEL_FONT = {
+ small: { title: 10, date: 9 },
+ medium: { title: 12, date: 11 },
+ large: { title: 14, date: 13 },
+ xlarge: { title: 18, date: 16 }
+}
const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
+const labelOpacity = computed(() => Math.max(0.5, Math.min(1, fl.value.labelOpacity ?? 0.75)))
+const connectorLengthScale = computed(() => Math.max(0, Math.min(1, fl.value.labelConnectorLength ?? 0.2)))
+// 0 -> no connector, 1 -> ~5x current connector length (about 70px)
+const connectorLengthPx = computed(() => connectorLengthScale.value * 70)
+const labelGapPx = computed(() => 4 + connectorLengthPx.value)
const labelStyle = computed(() => ({
- maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
+ maxWidth: labelFont.value.title >= 18 ? '150px' : labelFont.value.title >= 14 ? '120px' : '90px',
+ '--label-gap': `${labelGapPx.value}px`,
+ '--label-connector-len': `${connectorLengthPx.value}px`,
+ '--label-opacity': labelOpacity.value.toFixed(2),
+ '--label-connector-color': labelColor.value,
+ '--label-connector-opacity': Math.max(0.45, labelOpacity.value * 0.9).toFixed(2)
+}))
+const connectorStyle = computed(() => ({
+ display: connectorLengthPx.value <= 0.5 ? 'none' : 'block'
}))
const titleStyle = computed(() => ({
fontSize: `${labelFont.value.title}px`,
color: labelColor.value,
- maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
+ maxWidth: labelFont.value.title >= 18 ? '150px' : labelFont.value.title >= 14 ? '120px' : '90px'
}))
const dateStyle = computed(() => ({
fontSize: `${labelFont.value.date}px`,
@@ -93,14 +122,18 @@ const dotStyle = computed(() => ({
height: `${dotSize.value}px`
}))
-// Two-layer box-shadow: tight bright core + wide soft halo
-const glowShadow = computed(() => {
- const size = fl.value.glowSize
+const innerStyle = computed(() => {
+ const size = fl.value.glowSize
const strength = fl.value.glowStrength
- const color = glowColor.value
- const core = alphaHex(Math.min(strength / 3, 1))
- const halo = alphaHex(Math.min(strength / 7, 1))
- return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
+ const color = glowColor.value
+ const core = alphaHex(Math.min(strength / 3, 1))
+ const halo = alphaHex(Math.min(strength / 7, 1))
+ const shadow = `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
+ const bw = fl.value.dotBorderWidth ?? 0
+ return {
+ boxShadow: shadow,
+ border: bw > 0 ? `${bw}px solid ${fl.value.dotBorderColor ?? '#ffffff'}` : 'none'
+ }
})
function alphaHex(a) {
@@ -163,25 +196,41 @@ function onSelect() {
position: absolute;
left: 50%;
transform: translateX(-50%);
- top: calc(100% + 6px);
+ top: calc(100% + var(--label-gap, 18px));
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
max-width: 90px;
pointer-events: none;
+ opacity: var(--label-opacity, 0.75);
}
/* When dot is in lower half, show label above */
.glow-dot--label-above .glow-dot__label {
top: auto;
- bottom: calc(100% + 6px);
+ bottom: calc(100% + var(--label-gap, 18px));
+}
+
+.glow-dot__connector {
+ position: absolute;
+ left: 50%;
+ width: 1px;
+ height: var(--label-connector-len, 14px);
+ top: calc(-1 * var(--label-connector-len, 14px));
+ transform: translateX(-50%);
+ background: var(--label-connector-color, #ffffff);
+ opacity: var(--label-connector-opacity, 0.65);
+}
+
+.glow-dot--label-above .glow-dot__connector {
+ top: auto;
+ bottom: calc(-1 * var(--label-connector-len, 14px));
}
.glow-dot__title {
font-size: 10px;
font-weight: 600;
- opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -193,7 +242,6 @@ function onSelect() {
.glow-dot__date {
font-size: 9px;
font-weight: 400;
- opacity: 0.4;
white-space: nowrap;
line-height: 1.2;
}
diff --git a/frontend/src/components/LifeWaveSettings.vue b/frontend/src/components/LifeWaveSettings.vue
index c36579a..2e1a665 100644
--- a/frontend/src/components/LifeWaveSettings.vue
+++ b/frontend/src/components/LifeWaveSettings.vue
@@ -17,6 +17,23 @@