thats-me/frontend/_src/components/FloatingLines.vue
2026-04-22 12:57:10 +02:00

571 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
ref="containerRef"
class="floating-lines-container"
:style="containerStyle"
></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import {
Scene,
OrthographicCamera,
WebGLRenderer,
PlaneGeometry,
Mesh,
ShaderMaterial,
Vector3,
Vector2,
Clock
} from 'three'
const props = defineProps({
lineCount: { type: [Array, Number], default: () => [10] },
numPoints: { type: Number, default: 0 },
pointXValues: { type: Array, default: () => [] },
pointYValues: { type: Array, default: () => [] },
pointColors: { type: Array, default: () => [] },
lineSpread: { type: Number, default: 0.05 },
fanSpread: { type: Number, default: 0.05 },
lineSharpness: { type: Number, default: 8.0 },
waveFrequency: { type: Number, default: 7.0 },
bezierCurvature: { type: Number, default: 0.2 },
circleRadiusPx: { type: Number, default: 75 },
circleGlowSize: { type: Number, default: 18 },
circleGlowStrength: { type: Number, default: 1.5 },
lineBrightness: { type: Number, default: 1.0 },
scrollContainer: { type: Object, default: null },
scrollUvScale: { type: Number, default: 0 },
animationSpeed: { type: Number, default: 1 },
linesGradient: { type: Array, default: () => ['#e947f5', '#2f4ba2', '#0a0a12'] },
bgColorCenter: { type: String, default: '#0a0514' },
bgColorEdge: { type: String, default: '#000000' },
backgroundImage: { type: String, default: '' },
mixBlendMode: { type: String, default: 'screen' },
parallax: { type: Boolean, default: false }
})
// FPS display
const fpsDisplay = ref(0)
const dprDisplay = ref('0.0')
const containerStyle = computed(() => {
const style = {}
if (props.backgroundImage) {
style.backgroundImage = `url('${props.backgroundImage}')`
style.backgroundSize = 'cover'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
}
return style
})
// --- Shader Definitions ---
const vertexShader = `
precision highp float;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const fragmentShader = `
precision mediump float;
uniform float iTime;
uniform vec3 iResolution;
uniform float animationSpeed;
uniform int middleLineCount;
uniform int numPoints;
uniform float pointX[16];
uniform float pointY[16];
uniform float lineSpread;
uniform float fanSpread;
uniform float lineSharpness;
uniform float waveFrequency;
uniform float bezierCurvature;
uniform float lineBrightness;
uniform vec3 pointColor[16];
uniform bool parallax;
uniform vec2 parallaxOffset;
uniform vec3 lineGradient[8];
uniform int lineGradientCount;
uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge;
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
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; }
}
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);
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;
}
// 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);
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);
}
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);
const int MAX_PTS = 16;
const int MAX_SEGS = 15;
for (int s = 0; s < MAX_SEGS; ++s) {
if (s >= numPoints - 1) break;
vec2 sp = vec2(pointX[s], pointY[s]);
vec2 ep = vec2(pointX[s + 1], pointY[s + 1]);
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;
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
vec3 lineCol = 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 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;
for (int i = 0; i < middleLineCount; ++i) {
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
}
}
col *= lineBrightness;
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;
}
`
// --- Helpers ---
const MAX_GRADIENT_STOPS = 8
function hexToVec3(hex) {
let value = hex.trim()
if (value.startsWith('#')) value = value.slice(1)
let r = 255, g = 255, 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)
}
// --- Component Logic ---
const containerRef = ref(null)
let scene = null
let camera = null
let renderer = null
let material = null
let geometry = null
let mesh = null
let clock = null
let rafId = null
let resizeObserver = null
let uniforms = null
let scrollHandler = null
let scrollIdleTimer = null
let visibilityHandler = null
// Parallax tracking
let targetParallax = null
let currentParallax = null
const parallaxDamping = 0.05
function getLineCount() {
return typeof props.lineCount === 'number' ? props.lineCount : (props.lineCount[0] ?? 6)
}
function applyGradient() {
if (!uniforms) return
const lines = props.linesGradient.filter(s => s && s.trim().length > 0)
const stops = lines.slice(0, MAX_GRADIENT_STOPS)
uniforms.lineGradientCount.value = stops.length
stops.forEach((hex, i) => {
const c = hexToVec3(hex)
uniforms.lineGradient.value[i].set(c.x, c.y, c.z)
})
}
function applyPointColors() {
if (!uniforms) return
for (let i = 0; i < 16; i++) {
const hex = props.pointColors[i]
if (hex) {
const c = hexToVec3(hex)
uniforms.pointColor.value[i].set(c.x, c.y, c.z)
} else {
uniforms.pointColor.value[i].set(1, 1, 1)
}
}
}
function applyBgColors() {
if (!uniforms) return
const center = hexToVec3(props.bgColorCenter)
uniforms.bgColorCenter.value.set(center.x, center.y, center.z)
const edge = hexToVec3(props.bgColorEdge)
uniforms.bgColorEdge.value.set(edge.x, edge.y, edge.z)
}
// Watch all props for live updates
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
watch(() => props.lineCount, () => {
if (!uniforms) return
uniforms.middleLineCount.value = getLineCount()
})
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.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 })
watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.value = v })
watch(() => props.pointXValues, (values) => {
if (!uniforms) return
for (let i = 0; i < 16; i++) {
uniforms.pointX.value[i] = values[i] ?? 0
}
}, { deep: true })
watch(() => props.pointYValues, (values) => {
if (!uniforms) return
for (let i = 0; i < 16; i++) {
uniforms.pointY.value[i] = values[i] ?? 0
}
}, { deep: true })
watch(() => props.pointColors, applyPointColors, { deep: true })
watch(() => props.linesGradient, applyGradient, { deep: true })
watch(() => props.bgColorCenter, applyBgColors)
watch(() => props.bgColorEdge, applyBgColors)
onMounted(() => {
if (!containerRef.value) return
targetParallax = new Vector2(0, 0)
currentParallax = new Vector2(0, 0)
scene = new Scene()
camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
camera.position.z = 1
// Adaptive DPR: full quality when idle, reduced during scroll
const nativeDpr = Math.min(window.devicePixelRatio || 1, 2)
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
const DPR_IDLE = isMobile ? Math.min(nativeDpr, 1.6) : nativeDpr
const DPR_SCROLL = isMobile ? 0.75 : nativeDpr // reduced on mobile during scroll
const DPR_RESTORE_DELAY = 100 // ms after scroll stops to restore quality
let currentDpr = DPR_IDLE
let scrolling = false
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' })
renderer.setPixelRatio(currentDpr)
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.display = 'block'
renderer.domElement.style.mixBlendMode = props.mixBlendMode
containerRef.value.appendChild(renderer.domElement)
const middleLineCount = getLineCount()
// Initial point positions (UV space, no flip)
const initX = [...props.pointXValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
const initY = [...props.pointYValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
uniforms = {
iTime: { value: 0 },
iResolution: { value: new Vector3(1, 1, 1) },
animationSpeed: { value: props.animationSpeed },
middleLineCount: { value: middleLineCount },
numPoints: { value: props.numPoints },
pointX: { value: initX },
pointY: { value: initY },
lineSpread: { value: props.lineSpread },
fanSpread: { value: props.fanSpread },
lineSharpness: { value: props.lineSharpness },
waveFrequency: { value: props.waveFrequency },
bezierCurvature: { value: props.bezierCurvature },
lineBrightness: { value: props.lineBrightness },
pointColor: {
value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
},
parallax: { value: props.parallax },
parallaxOffset: { value: new Vector2(0, 0) },
lineGradient: {
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1))
},
lineGradientCount: { value: 0 },
bgColorCenter: { value: new Vector3(0, 0, 0) },
bgColorEdge: { value: new Vector3(0, 0, 0) }
}
// Apply initial values
applyGradient()
applyBgColors()
applyPointColors()
material = new ShaderMaterial({
uniforms,
vertexShader,
fragmentShader
})
geometry = new PlaneGeometry(2, 2)
mesh = new Mesh(geometry, material)
scene.add(mesh)
clock = new Clock()
// Resize
const setSize = () => {
if (!containerRef.value || !renderer) return
const width = containerRef.value.clientWidth || 1
const height = containerRef.value.clientHeight || 1
renderer.setSize(width, height, false)
const canvasWidth = renderer.domElement.width
const canvasHeight = renderer.domElement.height
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1)
}
setSize()
resizeObserver = new ResizeObserver(setSize)
resizeObserver.observe(containerRef.value)
// Pointer events (parallax only)
if (props.parallax) {
const handlePointerMove = (event) => {
const rect = renderer.domElement.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
targetParallax.set(
((x - centerX) / rect.width) * 0.2,
(-(y - centerY) / rect.height) * 0.2
)
}
renderer.domElement.addEventListener('pointermove', handlePointerMove)
}
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
let cachedScrollLeft = 0
function setDpr(dpr) {
if (dpr === currentDpr) return
currentDpr = dpr
renderer.setPixelRatio(dpr)
// Re-apply size so resolution updates
if (containerRef.value) {
const w = containerRef.value.clientWidth || 1
const h = containerRef.value.clientHeight || 1
renderer.setSize(w, h, false)
uniforms.iResolution.value.set(renderer.domElement.width, renderer.domElement.height, 1)
}
}
scrollHandler = () => {
if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
}
// Drop DPR while scrolling on mobile
if (!scrolling && DPR_SCROLL < DPR_IDLE) {
scrolling = true
setDpr(DPR_SCROLL)
}
clearTimeout(scrollIdleTimer)
scrollIdleTimer = setTimeout(() => {
scrolling = false
setDpr(DPR_IDLE)
}, DPR_RESTORE_DELAY)
}
if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
}
// Fast inline scroll sync — reads cached scrollLeft instead of DOM during render
function syncScrollFromCache() {
if (props.scrollUvScale > 0) {
const sl = cachedScrollLeft
for (let i = 0; i < 16; i++) {
uniforms.pointX.value[i] = (props.pointXValues[i] ?? 0) - sl * props.scrollUvScale
}
}
}
// FPS tracking + adaptive DPR
let frameCount = 0
let fpsLastTime = performance.now()
// Adaptive DPR steps based on measured FPS (mobile only)
// Steps are tried from lowest to highest first step where fps >= threshold wins
const DPR_STEPS = isMobile ? [
{ threshold: 50, dpr: DPR_IDLE }, // good fps → full quality
{ threshold: 35, dpr: Math.max(DPR_IDLE * 0.75, 0.75) }, // mid fps → 75%
{ threshold: 0, dpr: Math.max(DPR_IDLE * 0.5, 0.5) }, // low fps → 50%
] : null
let adaptiveDprTarget = DPR_IDLE
// Pause rendering when app/tab is hidden (e.g. iPhone home screen)
visibilityHandler = () => {
if (document.hidden) {
if (rafId) cancelAnimationFrame(rafId)
rafId = null
} else if (!rafId) {
fpsLastTime = performance.now()
frameCount = 0
renderLoop()
}
}
document.addEventListener('visibilitychange', visibilityHandler)
// Render loop
const renderLoop = () => {
// FPS counter + adaptive DPR
frameCount++
const now = performance.now()
if (now - fpsLastTime >= 1000) {
const fps = Math.round(frameCount / ((now - fpsLastTime) / 1000))
fpsDisplay.value = fps
frameCount = 0
fpsLastTime = now
// Adapt DPR based on measured FPS (only when not scroll-throttled)
if (DPR_STEPS && !scrolling) {
const step = DPR_STEPS.find(s => fps >= s.threshold) ?? DPR_STEPS[DPR_STEPS.length - 1]
if (step.dpr !== adaptiveDprTarget) {
adaptiveDprTarget = step.dpr
setDpr(adaptiveDprTarget)
}
}
dprDisplay.value = currentDpr.toFixed(2)
}
// Read latest scrollLeft from DOM in case scroll event was missed
if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
}
uniforms.iTime.value = clock.getElapsedTime()
if (props.parallax) {
currentParallax.lerp(targetParallax, parallaxDamping)
uniforms.parallaxOffset.value.copy(currentParallax)
}
syncScrollFromCache()
renderer.render(scene, camera)
rafId = requestAnimationFrame(renderLoop)
}
renderLoop()
})
defineExpose({ fpsDisplay, dprDisplay })
onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId)
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
if (resizeObserver) resizeObserver.disconnect()
if (props.scrollContainer && scrollHandler) {
props.scrollContainer.removeEventListener('scroll', scrollHandler)
}
clearTimeout(scrollIdleTimer)
if (geometry) geometry.dispose()
if (material) material.dispose()
if (renderer) {
renderer.dispose()
if (renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement)
}
}
})
</script>
<style scoped>
.floating-lines-container {
position: absolute;
inset: 0;
overflow: hidden;
will-change: transform;
transform: translateZ(0);
}
</style>