mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Merge branch 'main' into feat/infinite-menu
This commit is contained in:
@@ -19,10 +19,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="hero-main-content">
|
<div class="hero-main-content">
|
||||||
<router-link to="/backgrounds/liquid-ether" class="hero-new-badge-container">
|
<router-link to="/backgrounds/laser-flow" class="hero-new-badge-container">
|
||||||
<span class="hero-new-badge">New 🎉</span>
|
<span class="hero-new-badge">New 🎉</span>
|
||||||
<div class="hero-new-badge-text">
|
<div class="hero-new-badge-text">
|
||||||
<span>Liquid Ether</span>
|
<span>Laser Flow</span>
|
||||||
<i class="pi-arrow-right pi" style="font-size: 0.8rem"></i>
|
<i class="pi-arrow-right pi" style="font-size: 0.8rem"></i>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Highlighted sidebar items
|
// Highlighted sidebar items
|
||||||
export const NEW = ['Infinite Menu', 'Liquid Ether', 'Staggered Menu', 'Pixel Blast', 'Gradual Blur', 'Gradient Blinds', 'Bubble Menu', 'Prism', 'Plasma', 'Electric Border', 'Target Cursor', 'Pill Nav', 'Card Nav', 'Logo Loop', 'Prismatic Burst'];
|
export const NEW = ['Laser Flow', 'Dome Gallery', 'Liquid Ether', 'Staggered Menu', 'Pixel Blast', 'Gradual Blur', 'Gradient Blinds', 'Bubble Menu', 'Prism', 'Plasma', 'Electric Border', 'Target Cursor', 'Pill Nav', 'Card Nav', 'Logo Loop', 'Prismatic Burst'];
|
||||||
export const UPDATED = [];
|
export const UPDATED = [];
|
||||||
|
|
||||||
// Used for main sidebar navigation
|
// Used for main sidebar navigation
|
||||||
@@ -37,6 +37,7 @@ export const CATEGORIES = [
|
|||||||
'Animated Content',
|
'Animated Content',
|
||||||
'Fade Content',
|
'Fade Content',
|
||||||
'Gradual Blur',
|
'Gradual Blur',
|
||||||
|
'Laser Flow',
|
||||||
'Noise',
|
'Noise',
|
||||||
'Splash Cursor',
|
'Splash Cursor',
|
||||||
'Logo Loop',
|
'Logo Loop',
|
||||||
@@ -87,6 +88,7 @@ export const CATEGORIES = [
|
|||||||
'Tilted Card',
|
'Tilted Card',
|
||||||
'Glass Icons',
|
'Glass Icons',
|
||||||
'Decay Card',
|
'Decay Card',
|
||||||
|
'Dome Gallery',
|
||||||
'Flowing Menu',
|
'Flowing Menu',
|
||||||
'Elastic Slider',
|
'Elastic Slider',
|
||||||
'Stack',
|
'Stack',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const animations = {
|
|||||||
'sticker-peel': () => import('../demo/Animations/StickerPeelDemo.vue'),
|
'sticker-peel': () => import('../demo/Animations/StickerPeelDemo.vue'),
|
||||||
'electric-border': () => import('../demo/Animations/ElectricBorderDemo.vue'),
|
'electric-border': () => import('../demo/Animations/ElectricBorderDemo.vue'),
|
||||||
'gradual-blur': () => import('../demo/Animations/GradualBlurDemo.vue'),
|
'gradual-blur': () => import('../demo/Animations/GradualBlurDemo.vue'),
|
||||||
|
'laser-flow': () => import('../demo/Animations/LaserFlowDemo.vue'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const textAnimations = {
|
const textAnimations = {
|
||||||
@@ -70,6 +71,7 @@ const components = {
|
|||||||
'infinite-scroll': () => import('../demo/Components/InfiniteScrollDemo.vue'),
|
'infinite-scroll': () => import('../demo/Components/InfiniteScrollDemo.vue'),
|
||||||
'glass-icons': () => import('../demo/Components/GlassIconsDemo.vue'),
|
'glass-icons': () => import('../demo/Components/GlassIconsDemo.vue'),
|
||||||
'decay-card': () => import('../demo/Components/DecayCardDemo.vue'),
|
'decay-card': () => import('../demo/Components/DecayCardDemo.vue'),
|
||||||
|
'dome-gallery': () => import('../demo/Components/DomeGalleryDemo.vue'),
|
||||||
'flowing-menu': () => import('../demo/Components/FlowingMenuDemo.vue'),
|
'flowing-menu': () => import('../demo/Components/FlowingMenuDemo.vue'),
|
||||||
'elastic-slider': () => import('../demo/Components/ElasticSliderDemo.vue'),
|
'elastic-slider': () => import('../demo/Components/ElasticSliderDemo.vue'),
|
||||||
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
|
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
|
||||||
|
|||||||
@@ -3,7 +3,24 @@ import { createCodeObject } from '@/types/code';
|
|||||||
|
|
||||||
export const gradualBlur = createCodeObject(code, 'Animations/GradualBlur', {
|
export const gradualBlur = createCodeObject(code, 'Animations/GradualBlur', {
|
||||||
installation: `npm install mathjs`,
|
installation: `npm install mathjs`,
|
||||||
usage: `
|
usage: `<template>
|
||||||
|
<section style="position: relative; height: 500px; overflow: hidden;">
|
||||||
|
<div style="height: 100%; overflow-y: auto; padding: 6rem 2rem;">
|
||||||
|
<!-- Content Here - such as an image or text -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GradualBlur
|
||||||
|
target="parent"
|
||||||
|
position="bottom"
|
||||||
|
height="6rem"
|
||||||
|
:strength="2"
|
||||||
|
:divCount="5"
|
||||||
|
curve="bezier"
|
||||||
|
:exponential="true"
|
||||||
|
:opacity="1"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GradualBlur from "./GradualBlur.vue";
|
import GradualBlur from "./GradualBlur.vue";
|
||||||
|
|||||||
99
src/constants/code/Animations/laserFlowCode.ts
Normal file
99
src/constants/code/Animations/laserFlowCode.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import code from '@/content/Animations/LaserFlow/LaserFlow.vue?raw';
|
||||||
|
import { createCodeObject } from '@/types/code';
|
||||||
|
|
||||||
|
export const laserFlow = createCodeObject(code, 'Animations/LaserFlow', {
|
||||||
|
installation: `npm install three`,
|
||||||
|
usage: `<template>
|
||||||
|
<div
|
||||||
|
style="height: 800px; position: relative; overflow: hidden; background-color: #060010;"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
>
|
||||||
|
<LaserFlow
|
||||||
|
:horizontalBeamOffset="0.1"
|
||||||
|
:verticalBeamOffset="0.0"
|
||||||
|
color="#FF79C6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 86%;
|
||||||
|
height: 60%;
|
||||||
|
background-color: #060010;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px solid #FF79C6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
z-index: 6;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Your content here -->
|
||||||
|
Example Box Content
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
ref="revealImgRef"
|
||||||
|
src="/path/to/image.jpg"
|
||||||
|
alt="Reveal effect"
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
top: -50%;
|
||||||
|
z-index: 5;
|
||||||
|
mix-blend-mode: lighten;
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
--mx: -9999px;
|
||||||
|
--my: -9999px;
|
||||||
|
-webkit-mask-image: radial-gradient(circle at var(--mx) var(--my),
|
||||||
|
rgba(255,255,255,1) 0px,
|
||||||
|
rgba(255,255,255,0.95) 60px,
|
||||||
|
rgba(255,255,255,0.6) 120px,
|
||||||
|
rgba(255,255,255,0.25) 180px,
|
||||||
|
rgba(255,255,255,0) 240px);
|
||||||
|
mask-image: radial-gradient(circle at var(--mx) var(--my),
|
||||||
|
rgba(255,255,255,1) 0px,
|
||||||
|
rgba(255,255,255,0.95) 60px,
|
||||||
|
rgba(255,255,255,0.6) 120px,
|
||||||
|
rgba(255,255,255,0.25) 180px,
|
||||||
|
rgba(255,255,255,0) 240px);
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTemplateRef } from 'vue'
|
||||||
|
import LaserFlow from './LaserFlow.vue'
|
||||||
|
|
||||||
|
const revealImgRef = useTemplateRef('revealImgRef')
|
||||||
|
|
||||||
|
function handleMouseMove(e) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
const el = revealImgRef.value
|
||||||
|
if (el) {
|
||||||
|
el.style.setProperty('--mx', '\${x}px')
|
||||||
|
el.style.setProperty('--my', '\${y + rect.height * 0.5}px')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
const el = revealImgRef.value
|
||||||
|
if (el) {
|
||||||
|
el.style.setProperty('--mx', '-9999px')
|
||||||
|
el.style.setProperty('--my', '-9999px')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
30
src/constants/code/Components/domeGalleryCode.ts
Normal file
30
src/constants/code/Components/domeGalleryCode.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import code from '@content/Components/DomeGallery/DomeGallery.vue?raw';
|
||||||
|
import { createCodeObject } from '../../../types/code';
|
||||||
|
|
||||||
|
export const domeGallery = createCodeObject(code, 'Components/DomeGallery', {
|
||||||
|
installation: `# No external dependencies required`,
|
||||||
|
usage: `<template>
|
||||||
|
<DomeGallery
|
||||||
|
:images="[
|
||||||
|
'https://images.unsplash.com/photo-1755331039789-7e5680e26e8f?q=80&w=774&auto=format&fit=crop',
|
||||||
|
'https://images.unsplash.com/photo-1755569309049-98410b94f66d?q=80&w=772&auto=format&fit=crop',
|
||||||
|
'https://images.unsplash.com/photo-1755497595318-7e5e3523854f?q=80&w=774&auto=format&fit=crop'
|
||||||
|
]"
|
||||||
|
:fit="0.8"
|
||||||
|
fit-basis="auto"
|
||||||
|
:min-radius="600"
|
||||||
|
:segments="34"
|
||||||
|
:drag-sensitivity="20"
|
||||||
|
:enlarge-transition-ms="300"
|
||||||
|
:grayscale="true"
|
||||||
|
overlay-blur-color="#060010"
|
||||||
|
image-border-radius="30px"
|
||||||
|
opened-image-width="250px"
|
||||||
|
opened-image-height="350px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DomeGallery from "./DomeGallery.vue";
|
||||||
|
</script>`
|
||||||
|
});
|
||||||
615
src/content/Animations/LaserFlow/LaserFlow.vue
Normal file
615
src/content/Animations/LaserFlow/LaserFlow.vue
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, type CSSProperties } from 'vue';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
wispDensity?: number;
|
||||||
|
dpr?: number;
|
||||||
|
mouseSmoothTime?: number;
|
||||||
|
mouseTiltStrength?: number;
|
||||||
|
horizontalBeamOffset?: number;
|
||||||
|
verticalBeamOffset?: number;
|
||||||
|
flowSpeed?: number;
|
||||||
|
verticalSizing?: number;
|
||||||
|
horizontalSizing?: number;
|
||||||
|
fogIntensity?: number;
|
||||||
|
fogScale?: number;
|
||||||
|
wispSpeed?: number;
|
||||||
|
wispIntensity?: number;
|
||||||
|
flowStrength?: number;
|
||||||
|
decay?: number;
|
||||||
|
falloffStart?: number;
|
||||||
|
fogFallSpeed?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERT = `
|
||||||
|
precision highp float;
|
||||||
|
attribute vec3 position;
|
||||||
|
void main(){
|
||||||
|
gl_Position = vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FRAG = `
|
||||||
|
#ifdef GL_ES
|
||||||
|
#extension GL_OES_standard_derivatives : enable
|
||||||
|
#endif
|
||||||
|
precision highp float;
|
||||||
|
precision mediump int;
|
||||||
|
|
||||||
|
uniform float iTime;
|
||||||
|
uniform vec3 iResolution;
|
||||||
|
uniform vec4 iMouse;
|
||||||
|
uniform float uWispDensity;
|
||||||
|
uniform float uTiltScale;
|
||||||
|
uniform float uFlowTime;
|
||||||
|
uniform float uFogTime;
|
||||||
|
uniform float uBeamXFrac;
|
||||||
|
uniform float uBeamYFrac;
|
||||||
|
uniform float uFlowSpeed;
|
||||||
|
uniform float uVLenFactor;
|
||||||
|
uniform float uHLenFactor;
|
||||||
|
uniform float uFogIntensity;
|
||||||
|
uniform float uFogScale;
|
||||||
|
uniform float uWSpeed;
|
||||||
|
uniform float uWIntensity;
|
||||||
|
uniform float uFlowStrength;
|
||||||
|
uniform float uDecay;
|
||||||
|
uniform float uFalloffStart;
|
||||||
|
uniform float uFogFallSpeed;
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform float uFade;
|
||||||
|
|
||||||
|
// Core beam/flare shaping and dynamics
|
||||||
|
#define PI 3.14159265359
|
||||||
|
#define TWO_PI 6.28318530718
|
||||||
|
#define EPS 1e-6
|
||||||
|
#define EDGE_SOFT (DT_LOCAL*4.0)
|
||||||
|
#define DT_LOCAL 0.0038
|
||||||
|
#define TAP_RADIUS 6
|
||||||
|
#define R_H 150.0
|
||||||
|
#define R_V 150.0
|
||||||
|
#define FLARE_HEIGHT 16.0
|
||||||
|
#define FLARE_AMOUNT 8.0
|
||||||
|
#define FLARE_EXP 2.0
|
||||||
|
#define TOP_FADE_START 0.1
|
||||||
|
#define TOP_FADE_EXP 1.0
|
||||||
|
#define FLOW_PERIOD 0.5
|
||||||
|
#define FLOW_SHARPNESS 1.5
|
||||||
|
|
||||||
|
// Wisps (animated micro-streaks) that travel along the beam
|
||||||
|
#define W_BASE_X 1.5
|
||||||
|
#define W_LAYER_GAP 0.25
|
||||||
|
#define W_LANES 10
|
||||||
|
#define W_SIDE_DECAY 0.5
|
||||||
|
#define W_HALF 0.01
|
||||||
|
#define W_AA 0.15
|
||||||
|
#define W_CELL 20.0
|
||||||
|
#define W_SEG_MIN 0.01
|
||||||
|
#define W_SEG_MAX 0.55
|
||||||
|
#define W_CURVE_AMOUNT 15.0
|
||||||
|
#define W_CURVE_RANGE (FLARE_HEIGHT - 3.0)
|
||||||
|
#define W_BOTTOM_EXP 10.0
|
||||||
|
|
||||||
|
// Volumetric fog controls
|
||||||
|
#define FOG_ON 1
|
||||||
|
#define FOG_CONTRAST 1.2
|
||||||
|
#define FOG_SPEED_U 0.1
|
||||||
|
#define FOG_SPEED_V -0.1
|
||||||
|
#define FOG_OCTAVES 5
|
||||||
|
#define FOG_BOTTOM_BIAS 0.8
|
||||||
|
#define FOG_TILT_TO_MOUSE 0.05
|
||||||
|
#define FOG_TILT_DEADZONE 0.01
|
||||||
|
#define FOG_TILT_MAX_X 0.35
|
||||||
|
#define FOG_TILT_SHAPE 1.5
|
||||||
|
#define FOG_BEAM_MIN 0.0
|
||||||
|
#define FOG_BEAM_MAX 0.75
|
||||||
|
#define FOG_MASK_GAMMA 0.5
|
||||||
|
#define FOG_EXPAND_SHAPE 12.2
|
||||||
|
#define FOG_EDGE_MIX 0.5
|
||||||
|
|
||||||
|
// Horizontal vignette for the fog volume
|
||||||
|
#define HFOG_EDGE_START 0.20
|
||||||
|
#define HFOG_EDGE_END 0.98
|
||||||
|
#define HFOG_EDGE_GAMMA 1.4
|
||||||
|
#define HFOG_Y_RADIUS 25.0
|
||||||
|
#define HFOG_Y_SOFT 60.0
|
||||||
|
|
||||||
|
// Beam extents and edge masking
|
||||||
|
#define EDGE_X0 0.22
|
||||||
|
#define EDGE_X1 0.995
|
||||||
|
#define EDGE_X_GAMMA 1.25
|
||||||
|
#define EDGE_LUMA_T0 0.0
|
||||||
|
#define EDGE_LUMA_T1 2.0
|
||||||
|
#define DITHER_STRENGTH 1.0
|
||||||
|
|
||||||
|
float g(float x){return x<=0.00031308?12.92*x:1.055*pow(x,1.0/2.4)-0.055;}
|
||||||
|
float bs(vec2 p,vec2 q,float powr){
|
||||||
|
float d=distance(p,q),f=powr*uFalloffStart,r=(f*f)/(d*d+EPS);
|
||||||
|
return powr*min(1.0,r);
|
||||||
|
}
|
||||||
|
float bsa(vec2 p,vec2 q,float powr,vec2 s){
|
||||||
|
vec2 d=p-q; float dd=(d.x*d.x)/(s.x*s.x)+(d.y*d.y)/(s.y*s.y),f=powr*uFalloffStart,r=(f*f)/(dd+EPS);
|
||||||
|
return powr*min(1.0,r);
|
||||||
|
}
|
||||||
|
float tri01(float x){float f=fract(x);return 1.0-abs(f*2.0-1.0);}
|
||||||
|
float tauWf(float t,float tmin,float tmax){float a=smoothstep(tmin,tmin+EDGE_SOFT,t),b=1.0-smoothstep(tmax-EDGE_SOFT,tmax,t);return max(0.0,a*b);}
|
||||||
|
float h21(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+34.123);return fract(p.x*p.y);}
|
||||||
|
float vnoise(vec2 p){
|
||||||
|
vec2 i=floor(p),f=fract(p);
|
||||||
|
float a=h21(i),b=h21(i+vec2(1,0)),c=h21(i+vec2(0,1)),d=h21(i+vec2(1,1));
|
||||||
|
vec2 u=f*f*(3.0-2.0*f);
|
||||||
|
return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
|
||||||
|
}
|
||||||
|
float fbm2(vec2 p){
|
||||||
|
float v=0.0,amp=0.6; mat2 m=mat2(0.86,0.5,-0.5,0.86);
|
||||||
|
for(int i=0;i<FOG_OCTAVES;++i){v+=amp*vnoise(p); p=m*p*2.03+17.1; amp*=0.52;}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
float rGate(float x,float l){float a=smoothstep(0.0,W_AA,x),b=1.0-smoothstep(l,l+W_AA,x);return max(0.0,a*b);}
|
||||||
|
float flareY(float y){float t=clamp(1.0-(clamp(y,0.0,FLARE_HEIGHT)/max(FLARE_HEIGHT,EPS)),0.0,1.0);return pow(t,FLARE_EXP);}
|
||||||
|
|
||||||
|
float vWisps(vec2 uv,float topF){
|
||||||
|
float y=uv.y,yf=(y+uFlowTime*uWSpeed)/W_CELL;
|
||||||
|
float dRaw=clamp(uWispDensity,0.0,2.0),d=dRaw<=0.0?1.0:dRaw;
|
||||||
|
float lanesF=floor(float(W_LANES)*min(d,1.0)+0.5); // WebGL1-safe
|
||||||
|
int lanes=int(max(1.0,lanesF));
|
||||||
|
float sp=min(d,1.0),ep=max(d-1.0,0.0);
|
||||||
|
float fm=flareY(max(y,0.0)),rm=clamp(1.0-(y/max(W_CURVE_RANGE,EPS)),0.0,1.0),cm=fm*rm;
|
||||||
|
const float G=0.05; float xS=1.0+(FLARE_AMOUNT*W_CURVE_AMOUNT*G)*cm;
|
||||||
|
float sPix=clamp(y/R_V,0.0,1.0),bGain=pow(1.0-sPix,W_BOTTOM_EXP),sum=0.0;
|
||||||
|
for(int s=0;s<2;++s){
|
||||||
|
float sgn=s==0?-1.0:1.0;
|
||||||
|
for(int i=0;i<W_LANES;++i){
|
||||||
|
if(i>=lanes) break;
|
||||||
|
float off=W_BASE_X+float(i)*W_LAYER_GAP,xc=sgn*(off*xS);
|
||||||
|
float dx=abs(uv.x-xc),lat=1.0-smoothstep(W_HALF,W_HALF+W_AA,dx),amp=exp(-off*W_SIDE_DECAY);
|
||||||
|
float seed=h21(vec2(off,sgn*17.0)),yf2=yf+seed*7.0,ci=floor(yf2),fy=fract(yf2);
|
||||||
|
float seg=mix(W_SEG_MIN,W_SEG_MAX,h21(vec2(ci,off*2.3)));
|
||||||
|
float spR=h21(vec2(ci,off+sgn*31.0)),seg1=rGate(fy,seg)*step(spR,sp);
|
||||||
|
if(ep>0.0){float spR2=h21(vec2(ci*3.1+7.0,off*5.3+sgn*13.0)); float f2=fract(fy+0.5); seg1+=rGate(f2,seg*0.9)*step(spR2,ep);}
|
||||||
|
sum+=amp*lat*seg1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float span=smoothstep(-3.0,0.0,y)*(1.0-smoothstep(R_V-6.0,R_V,y));
|
||||||
|
return uWIntensity*sum*topF*bGain*span;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mainImage(out vec4 fc,in vec2 frag){
|
||||||
|
vec2 C=iResolution.xy*.5; float invW=1.0/max(C.x,1.0);
|
||||||
|
float sc=512.0/iResolution.x*.4;
|
||||||
|
vec2 uv=(frag-C)*sc,off=vec2(uBeamXFrac*iResolution.x*sc,uBeamYFrac*iResolution.y*sc);
|
||||||
|
vec2 uvc = uv - off;
|
||||||
|
float a=0.0,b=0.0;
|
||||||
|
float basePhase=1.5*PI+uDecay*.5; float tauMin=basePhase-uDecay; float tauMax=basePhase;
|
||||||
|
float cx=clamp(uvc.x/(R_H*uHLenFactor),-1.0,1.0),tH=clamp(TWO_PI-acos(cx),tauMin,tauMax);
|
||||||
|
for(int k=-TAP_RADIUS;k<=TAP_RADIUS;++k){
|
||||||
|
float tu=tH+float(k)*DT_LOCAL,wt=tauWf(tu,tauMin,tauMax); if(wt<=0.0) continue;
|
||||||
|
float spd=max(abs(sin(tu)),0.02),u=clamp((basePhase-tu)/max(uDecay,EPS),0.0,1.0),env=pow(1.0-abs(u*2.0-1.0),0.8);
|
||||||
|
vec2 p=vec2((R_H*uHLenFactor)*cos(tu),0.0);
|
||||||
|
a+=wt*bs(uvc,p,env*spd);
|
||||||
|
}
|
||||||
|
float yPix=uvc.y,cy=clamp(-yPix/(R_V*uVLenFactor),-1.0,1.0),tV=clamp(TWO_PI-acos(cy),tauMin,tauMax);
|
||||||
|
for(int k=-TAP_RADIUS;k<=TAP_RADIUS;++k){
|
||||||
|
float tu=tV+float(k)*DT_LOCAL,wt=tauWf(tu,tauMin,tauMax); if(wt<=0.0) continue;
|
||||||
|
float yb=(-R_V)*cos(tu),s=clamp(yb/R_V,0.0,1.0),spd=max(abs(sin(tu)),0.02);
|
||||||
|
float env=pow(1.0-s,0.6)*spd;
|
||||||
|
float cap=1.0-smoothstep(TOP_FADE_START,1.0,s); cap=pow(cap,TOP_FADE_EXP); env*=cap;
|
||||||
|
float ph=s/max(FLOW_PERIOD,EPS)+uFlowTime*uFlowSpeed;
|
||||||
|
float fl=pow(tri01(ph),FLOW_SHARPNESS);
|
||||||
|
env*=mix(1.0-uFlowStrength,1.0,fl);
|
||||||
|
float yp=(-R_V*uVLenFactor)*cos(tu),m=pow(smoothstep(FLARE_HEIGHT,0.0,yp),FLARE_EXP),wx=1.0+FLARE_AMOUNT*m;
|
||||||
|
vec2 sig=vec2(wx,1.0),p=vec2(0.0,yp);
|
||||||
|
float mask=step(0.0,yp);
|
||||||
|
b+=wt*bsa(uvc,p,mask*env,sig);
|
||||||
|
}
|
||||||
|
float sPix=clamp(yPix/R_V,0.0,1.0),topA=pow(1.0-smoothstep(TOP_FADE_START,1.0,sPix),TOP_FADE_EXP);
|
||||||
|
float L=a+b*topA;
|
||||||
|
float w=vWisps(vec2(uvc.x,yPix),topA);
|
||||||
|
float fog=0.0;
|
||||||
|
#if FOG_ON
|
||||||
|
vec2 fuv=uvc*uFogScale;
|
||||||
|
float mAct=step(1.0,length(iMouse.xy)),nx=((iMouse.x-C.x)*invW)*mAct;
|
||||||
|
float ax = abs(nx);
|
||||||
|
float stMag = mix(ax, pow(ax, FOG_TILT_SHAPE), 0.35);
|
||||||
|
float st = sign(nx) * stMag * uTiltScale;
|
||||||
|
st = clamp(st, -FOG_TILT_MAX_X, FOG_TILT_MAX_X);
|
||||||
|
vec2 dir=normalize(vec2(st,1.0));
|
||||||
|
fuv+=uFogTime*uFogFallSpeed*dir;
|
||||||
|
vec2 prp=vec2(-dir.y,dir.x);
|
||||||
|
fuv+=prp*(0.08*sin(dot(uvc,prp)*0.08+uFogTime*0.9));
|
||||||
|
float n=fbm2(fuv+vec2(fbm2(fuv+vec2(7.3,2.1)),fbm2(fuv+vec2(-3.7,5.9)))*0.6);
|
||||||
|
n=pow(clamp(n,0.0,1.0),FOG_CONTRAST);
|
||||||
|
float pixW = 1.0 / max(iResolution.y, 1.0);
|
||||||
|
#ifdef GL_OES_standard_derivatives
|
||||||
|
float wL = max(fwidth(L), pixW);
|
||||||
|
#else
|
||||||
|
float wL = pixW;
|
||||||
|
#endif
|
||||||
|
float m0=pow(smoothstep(FOG_BEAM_MIN - wL, FOG_BEAM_MAX + wL, L),FOG_MASK_GAMMA);
|
||||||
|
float bm=1.0-pow(1.0-m0,FOG_EXPAND_SHAPE); bm=mix(bm*m0,bm,FOG_EDGE_MIX);
|
||||||
|
float yP=1.0-smoothstep(HFOG_Y_RADIUS,HFOG_Y_RADIUS+HFOG_Y_SOFT,abs(yPix));
|
||||||
|
float nxF=abs((frag.x-C.x)*invW),hE=1.0-smoothstep(HFOG_EDGE_START,HFOG_EDGE_END,nxF); hE=pow(clamp(hE,0.0,1.0),HFOG_EDGE_GAMMA);
|
||||||
|
float hW=mix(1.0,hE,clamp(yP,0.0,1.0));
|
||||||
|
float bBias=mix(1.0,1.0-sPix,FOG_BOTTOM_BIAS);
|
||||||
|
float browserFogIntensity = uFogIntensity;
|
||||||
|
browserFogIntensity *= 1.8;
|
||||||
|
|
||||||
|
float radialFade = 1.0 - smoothstep(0.0, 0.7, length(uvc) / 120.0);
|
||||||
|
float safariFog = n * browserFogIntensity * bBias * bm * hW * radialFade;
|
||||||
|
fog = safariFog;
|
||||||
|
#endif
|
||||||
|
float LF=L+fog;
|
||||||
|
float dith=(h21(frag)-0.5)*(DITHER_STRENGTH/255.0);
|
||||||
|
float tone=g(LF+w);
|
||||||
|
vec3 col=tone*uColor+dith;
|
||||||
|
float alpha=clamp(g(L+w*0.6)+dith*0.6,0.0,1.0);
|
||||||
|
float nxE=abs((frag.x-C.x)*invW),xF=pow(clamp(1.0-smoothstep(EDGE_X0,EDGE_X1,nxE),0.0,1.0),EDGE_X_GAMMA);
|
||||||
|
float scene=LF+max(0.0,w)*0.5,hi=smoothstep(EDGE_LUMA_T0,EDGE_LUMA_T1,scene);
|
||||||
|
float eM=mix(xF,1.0,hi);
|
||||||
|
col*=eM; alpha*=eM;
|
||||||
|
col*=uFade; alpha*=uFade;
|
||||||
|
fc=vec4(col,alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main(){
|
||||||
|
vec4 fc;
|
||||||
|
mainImage(fc, gl_FragCoord.xy);
|
||||||
|
gl_FragColor = fc;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
wispDensity: 1,
|
||||||
|
mouseSmoothTime: 0.0,
|
||||||
|
mouseTiltStrength: 0.01,
|
||||||
|
horizontalBeamOffset: 0.1,
|
||||||
|
verticalBeamOffset: 0.0,
|
||||||
|
flowSpeed: 0.35,
|
||||||
|
verticalSizing: 2.0,
|
||||||
|
horizontalSizing: 0.5,
|
||||||
|
fogIntensity: 0.45,
|
||||||
|
fogScale: 0.3,
|
||||||
|
wispSpeed: 15.0,
|
||||||
|
wispIntensity: 5.0,
|
||||||
|
flowStrength: 0.25,
|
||||||
|
decay: 1.1,
|
||||||
|
falloffStart: 1.2,
|
||||||
|
fogFallSpeed: 0.6,
|
||||||
|
color: '#A0FFBC'
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountRef = useTemplateRef('mountRef');
|
||||||
|
const rendererRef = ref<THREE.WebGLRenderer | null>(null);
|
||||||
|
const uniformsRef = ref<any>(null);
|
||||||
|
const hasFadedRef = ref(false);
|
||||||
|
const rectRef = ref<DOMRect | null>(null);
|
||||||
|
const baseDprRef = ref<number>(1);
|
||||||
|
const currentDprRef = ref<number>(1);
|
||||||
|
const fpsSamplesRef = ref<number[]>([]);
|
||||||
|
const lastFpsCheckRef = ref<number>(performance.now());
|
||||||
|
const emaDtRef = ref<number>(16.7); // ms
|
||||||
|
const pausedRef = ref<boolean>(false);
|
||||||
|
const inViewRef = ref<boolean>(true);
|
||||||
|
|
||||||
|
const hexToRGB = (hex: string) => {
|
||||||
|
let c = hex.trim();
|
||||||
|
if (c[0] === '#') c = c.slice(1);
|
||||||
|
if (c.length === 3)
|
||||||
|
c = c
|
||||||
|
.split('')
|
||||||
|
.map(x => x + x)
|
||||||
|
.join('');
|
||||||
|
const n = parseInt(c, 16) || 0xffffff;
|
||||||
|
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const mount = mountRef.value!;
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: false,
|
||||||
|
alpha: false,
|
||||||
|
depth: false,
|
||||||
|
stencil: false,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
preserveDrawingBuffer: false,
|
||||||
|
failIfMajorPerformanceCaveat: false,
|
||||||
|
logarithmicDepthBuffer: false
|
||||||
|
});
|
||||||
|
|
||||||
|
rendererRef.value = renderer;
|
||||||
|
|
||||||
|
baseDprRef.value = Math.min(props.dpr ?? (window.devicePixelRatio || 1), 2);
|
||||||
|
currentDprRef.value = baseDprRef.value;
|
||||||
|
|
||||||
|
renderer.setPixelRatio(currentDprRef.value);
|
||||||
|
renderer.shadowMap.enabled = false;
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setClearColor(0x000000, 1);
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '100%';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
mount.appendChild(canvas);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
|
||||||
|
|
||||||
|
const { r, g, b } = hexToRGB(props.color || '#FFFFFF');
|
||||||
|
|
||||||
|
const uniforms = {
|
||||||
|
iTime: { value: 0 },
|
||||||
|
iResolution: { value: new THREE.Vector3(1, 1, 1) },
|
||||||
|
iMouse: { value: new THREE.Vector4(0, 0, 0, 0) },
|
||||||
|
uWispDensity: { value: props.wispDensity },
|
||||||
|
uTiltScale: { value: props.mouseTiltStrength },
|
||||||
|
uFlowTime: { value: 0 },
|
||||||
|
uFogTime: { value: 0 },
|
||||||
|
uBeamXFrac: { value: props.horizontalBeamOffset },
|
||||||
|
uBeamYFrac: { value: props.verticalBeamOffset },
|
||||||
|
uFlowSpeed: { value: props.flowSpeed },
|
||||||
|
uVLenFactor: { value: props.verticalSizing },
|
||||||
|
uHLenFactor: { value: props.horizontalSizing },
|
||||||
|
uFogIntensity: { value: props.fogIntensity },
|
||||||
|
uFogScale: { value: props.fogScale },
|
||||||
|
uWSpeed: { value: props.wispSpeed },
|
||||||
|
uWIntensity: { value: props.wispIntensity },
|
||||||
|
uFlowStrength: { value: props.flowStrength },
|
||||||
|
uDecay: { value: props.decay },
|
||||||
|
uFalloffStart: { value: props.falloffStart },
|
||||||
|
uFogFallSpeed: { value: props.fogFallSpeed },
|
||||||
|
uColor: { value: new THREE.Vector3(r, g, b) },
|
||||||
|
uFade: { value: hasFadedRef.value ? 1 : 0 }
|
||||||
|
};
|
||||||
|
uniformsRef.value = uniforms;
|
||||||
|
|
||||||
|
const material = new THREE.RawShaderMaterial({
|
||||||
|
vertexShader: VERT,
|
||||||
|
fragmentShader: FRAG,
|
||||||
|
uniforms,
|
||||||
|
transparent: false,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.NormalBlending
|
||||||
|
});
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let prevTime = 0;
|
||||||
|
|
||||||
|
let fade = hasFadedRef.value ? 1 : 0;
|
||||||
|
const mouseTarget = new THREE.Vector2(0, 0);
|
||||||
|
const mouseSmooth = new THREE.Vector2(0, 0);
|
||||||
|
|
||||||
|
const setSizeNow = () => {
|
||||||
|
const w = mount.clientWidth || 1;
|
||||||
|
const h = mount.clientHeight || 1;
|
||||||
|
const pr = currentDprRef.value;
|
||||||
|
renderer.setPixelRatio(pr);
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
uniforms.iResolution.value.set(w * pr, h * pr, pr);
|
||||||
|
rectRef.value = canvas.getBoundingClientRect();
|
||||||
|
};
|
||||||
|
|
||||||
|
let resizeRaf = 0;
|
||||||
|
const scheduleResize = () => {
|
||||||
|
if (resizeRaf) cancelAnimationFrame(resizeRaf);
|
||||||
|
resizeRaf = requestAnimationFrame(setSizeNow);
|
||||||
|
};
|
||||||
|
|
||||||
|
setSizeNow();
|
||||||
|
const ro = new ResizeObserver(scheduleResize);
|
||||||
|
ro.observe(mount);
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
inViewRef.value = entries[0]?.isIntersecting ?? true;
|
||||||
|
},
|
||||||
|
{ root: null, threshold: 0 }
|
||||||
|
);
|
||||||
|
io.observe(mount);
|
||||||
|
|
||||||
|
const onVis = () => {
|
||||||
|
pausedRef.value = document.hidden;
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVis, { passive: true });
|
||||||
|
|
||||||
|
const updateMouse = (clientX: number, clientY: number) => {
|
||||||
|
const rect = rectRef.value;
|
||||||
|
if (!rect) return;
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
const ratio = currentDprRef.value;
|
||||||
|
const hb = rect.height * ratio;
|
||||||
|
mouseTarget.set(x * ratio, hb - y * ratio);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (ev: PointerEvent | MouseEvent) => updateMouse(ev.clientX, ev.clientY);
|
||||||
|
const onLeave = () => mouseTarget.set(0, 0);
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', onMove as any, { passive: true });
|
||||||
|
canvas.addEventListener('pointerdown', onMove as any, { passive: true });
|
||||||
|
canvas.addEventListener('pointerenter', onMove as any, { passive: true });
|
||||||
|
canvas.addEventListener('pointerleave', onLeave as any, { passive: true });
|
||||||
|
|
||||||
|
const onCtxLost = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pausedRef.value = true;
|
||||||
|
};
|
||||||
|
const onCtxRestored = () => {
|
||||||
|
pausedRef.value = false;
|
||||||
|
scheduleResize();
|
||||||
|
};
|
||||||
|
canvas.addEventListener('webglcontextlost', onCtxLost, false);
|
||||||
|
canvas.addEventListener('webglcontextrestored', onCtxRestored, false);
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
|
const dprFloor = 0.6;
|
||||||
|
const lowerThresh = 50;
|
||||||
|
const upperThresh = 58;
|
||||||
|
|
||||||
|
const adjustDprIfNeeded = (now: number) => {
|
||||||
|
const elapsed = now - lastFpsCheckRef.value;
|
||||||
|
if (elapsed < 750) return;
|
||||||
|
|
||||||
|
const samples = fpsSamplesRef.value;
|
||||||
|
if (samples.length === 0) {
|
||||||
|
lastFpsCheckRef.value = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const avgFps = samples.reduce((a, b) => a + b, 0) / samples.length;
|
||||||
|
|
||||||
|
let next = currentDprRef.value;
|
||||||
|
const base = baseDprRef.value;
|
||||||
|
|
||||||
|
if (avgFps < lowerThresh) {
|
||||||
|
next = clamp(currentDprRef.value * 0.9, dprFloor, base);
|
||||||
|
} else if (avgFps > upperThresh && currentDprRef.value < base) {
|
||||||
|
next = clamp(currentDprRef.value * 1.05, dprFloor, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(next - currentDprRef.value) > 0.01) {
|
||||||
|
currentDprRef.value = next;
|
||||||
|
setSizeNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
fpsSamplesRef.value = [];
|
||||||
|
lastFpsCheckRef.value = now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
raf = requestAnimationFrame(animate);
|
||||||
|
if (pausedRef.value || !inViewRef.value) return;
|
||||||
|
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const dt = Math.max(0, t - prevTime);
|
||||||
|
prevTime = t;
|
||||||
|
|
||||||
|
const dtMs = dt * 1000;
|
||||||
|
emaDtRef.value = emaDtRef.value * 0.9 + dtMs * 0.1;
|
||||||
|
const instFps = 1000 / Math.max(1, emaDtRef.value);
|
||||||
|
fpsSamplesRef.value.push(instFps);
|
||||||
|
|
||||||
|
uniforms.iTime.value = t;
|
||||||
|
|
||||||
|
const cdt = Math.min(0.033, Math.max(0.001, dt));
|
||||||
|
(uniforms.uFlowTime.value as number) += cdt;
|
||||||
|
(uniforms.uFogTime.value as number) += cdt;
|
||||||
|
|
||||||
|
if (!hasFadedRef.value) {
|
||||||
|
const fadeDur = 1.0;
|
||||||
|
fade = Math.min(1, fade + cdt / fadeDur);
|
||||||
|
uniforms.uFade.value = fade;
|
||||||
|
if (fade >= 1) hasFadedRef.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tau = Math.max(1e-3, props.mouseSmoothTime);
|
||||||
|
const alpha = 1 - Math.exp(-cdt / tau);
|
||||||
|
mouseSmooth.lerp(mouseTarget, alpha);
|
||||||
|
uniforms.iMouse.value.set(mouseSmooth.x, mouseSmooth.y, 0, 0);
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
|
||||||
|
adjustDprIfNeeded(performance.now());
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
io.disconnect();
|
||||||
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
|
canvas.removeEventListener('pointermove', onMove as any);
|
||||||
|
canvas.removeEventListener('pointerdown', onMove as any);
|
||||||
|
canvas.removeEventListener('pointerenter', onMove as any);
|
||||||
|
canvas.removeEventListener('pointerleave', onLeave as any);
|
||||||
|
canvas.removeEventListener('webglcontextlost', onCtxLost);
|
||||||
|
canvas.removeEventListener('webglcontextrestored', onCtxRestored);
|
||||||
|
geometry.dispose();
|
||||||
|
material.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (mount.contains(canvas)) mount.removeChild(canvas);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanup?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.dpr],
|
||||||
|
() => {
|
||||||
|
cleanup?.();
|
||||||
|
setup();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.wispDensity,
|
||||||
|
props.mouseTiltStrength,
|
||||||
|
props.horizontalBeamOffset,
|
||||||
|
props.verticalBeamOffset,
|
||||||
|
props.flowSpeed,
|
||||||
|
props.verticalSizing,
|
||||||
|
props.horizontalSizing,
|
||||||
|
props.fogIntensity,
|
||||||
|
props.fogScale,
|
||||||
|
props.wispSpeed,
|
||||||
|
props.wispIntensity,
|
||||||
|
props.flowStrength,
|
||||||
|
props.decay,
|
||||||
|
props.falloffStart,
|
||||||
|
props.fogFallSpeed,
|
||||||
|
props.color
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
const uniforms = uniformsRef.value;
|
||||||
|
if (!uniforms) return;
|
||||||
|
|
||||||
|
uniforms.uWispDensity.value = props.wispDensity;
|
||||||
|
uniforms.uTiltScale.value = props.mouseTiltStrength;
|
||||||
|
uniforms.uBeamXFrac.value = props.horizontalBeamOffset;
|
||||||
|
uniforms.uBeamYFrac.value = props.verticalBeamOffset;
|
||||||
|
uniforms.uFlowSpeed.value = props.flowSpeed;
|
||||||
|
uniforms.uVLenFactor.value = props.verticalSizing;
|
||||||
|
uniforms.uHLenFactor.value = props.horizontalSizing;
|
||||||
|
uniforms.uFogIntensity.value = props.fogIntensity;
|
||||||
|
uniforms.uFogScale.value = props.fogScale;
|
||||||
|
uniforms.uWSpeed.value = props.wispSpeed;
|
||||||
|
uniforms.uWIntensity.value = props.wispIntensity;
|
||||||
|
uniforms.uFlowStrength.value = props.flowStrength;
|
||||||
|
uniforms.uDecay.value = props.decay;
|
||||||
|
uniforms.uFalloffStart.value = props.falloffStart;
|
||||||
|
uniforms.uFogFallSpeed.value = props.fogFallSpeed;
|
||||||
|
|
||||||
|
const { r, g, b } = hexToRGB(props.color || '#FFFFFF');
|
||||||
|
console.log(props.color);
|
||||||
|
uniforms.uColor.value.set(r, g, b);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="mountRef" :class="['w-full h-full relative', className]" :style="style" />
|
||||||
|
</template>
|
||||||
866
src/content/Components/DomeGallery/DomeGallery.vue
Normal file
866
src/content/Components/DomeGallery/DomeGallery.vue
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="rootRef"
|
||||||
|
class="relative w-full h-full box-border"
|
||||||
|
:style="{
|
||||||
|
'--segments-x': segments,
|
||||||
|
'--segments-y': segments,
|
||||||
|
'--overlay-blur-color': overlayBlurColor,
|
||||||
|
'--tile-radius': imageBorderRadius,
|
||||||
|
'--enlarge-radius': openedImageBorderRadius,
|
||||||
|
'--image-filter': grayscale ? 'grayscale(1)' : 'none',
|
||||||
|
'--radius': '520px',
|
||||||
|
'--viewer-pad': '72px',
|
||||||
|
'--circ': 'calc(var(--radius) * 3.14)',
|
||||||
|
'--rot-y': 'calc((360deg / var(--segments-x)) / 2)',
|
||||||
|
'--rot-x': 'calc((360deg / var(--segments-y)) / 2)',
|
||||||
|
'--item-width': 'calc(var(--circ) / var(--segments-x))',
|
||||||
|
'--item-height': 'calc(var(--circ) / var(--segments-y))'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<main ref="mainRef" class="absolute inset-0 grid place-items-center overflow-hidden touch-none select-none bg-transparent">
|
||||||
|
<div
|
||||||
|
class="w-full h-full grid place-items-center contain-layout contain-paint contain-size"
|
||||||
|
:style="{
|
||||||
|
perspective: 'calc(var(--radius) * 2)',
|
||||||
|
perspectiveOrigin: '50% 50%'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="sphereRef"
|
||||||
|
class="will-change-transform"
|
||||||
|
style="transform-style: preserve-3d; transform: translateZ(calc(var(--radius) * -1))"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, i) in items"
|
||||||
|
:key="`${item.x},${item.y},${i}`"
|
||||||
|
class="absolute -top-[999px] -bottom-[999px] -left-[999px] -right-[999px] m-auto transition-transform duration-300"
|
||||||
|
:data-src="item.src"
|
||||||
|
:data-offset-x="item.x"
|
||||||
|
:data-offset-y="item.y"
|
||||||
|
:data-size-x="item.sizeX"
|
||||||
|
:data-size-y="item.sizeY"
|
||||||
|
:style="{
|
||||||
|
'--offset-x': item.x,
|
||||||
|
'--offset-y': item.y,
|
||||||
|
'--item-size-x': item.sizeX,
|
||||||
|
'--item-size-y': item.sizeY,
|
||||||
|
width: 'calc(var(--item-width) * var(--item-size-x))',
|
||||||
|
height: 'calc(var(--item-height) * var(--item-size-y))',
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
transformOrigin: '50% 50%',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
transform: `rotateY(calc(var(--rot-y) * (var(--offset-x) + ((var(--item-size-x) - 1) / 2)) + var(--rot-y-delta, 0deg))) rotateX(calc(var(--rot-x) * (var(--offset-y) - ((var(--item-size-y) - 1) / 2)) + var(--rot-x-delta, 0deg))) translateZ(var(--radius))`
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute block inset-[10px] bg-transparent overflow-hidden transition-transform duration-300 cursor-pointer pointer-events-auto transform translate-z-0 focus:outline-none"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-label="item.alt || 'Open image'"
|
||||||
|
@click="onTileClick"
|
||||||
|
@pointerup="onTilePointerUp"
|
||||||
|
@touchend="onTileTouchEnd"
|
||||||
|
:style="{
|
||||||
|
borderRadius: 'var(--tile-radius, 12px)',
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
WebkitTransform: 'translateZ(0)'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="item.src"
|
||||||
|
draggable="false"
|
||||||
|
:alt="item.alt"
|
||||||
|
class="w-full h-full object-cover pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
filter: 'var(--image-filter, none)'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 m-auto z-[3] pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
backgroundImage: 'radial-gradient(rgba(235, 235, 235, 0) 65%, var(--overlay-blur-color, #060010) 100%)'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 m-auto z-[3] pointer-events-none"
|
||||||
|
:style="{
|
||||||
|
WebkitMaskImage: 'radial-gradient(rgba(235, 235, 235, 0) 70%, var(--overlay-blur-color, #060010) 90%)',
|
||||||
|
maskImage: 'radial-gradient(rgba(235, 235, 235, 0) 70%, var(--overlay-blur-color, #060010) 90%)',
|
||||||
|
backdropFilter: 'blur(3px)'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 h-[120px] z-[5] pointer-events-none top-0 rotate-180"
|
||||||
|
:style="{
|
||||||
|
background: 'linear-gradient(to bottom, transparent, var(--overlay-blur-color, #060010))'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 h-[120px] z-[5] pointer-events-none bottom-0"
|
||||||
|
:style="{
|
||||||
|
background: 'linear-gradient(to bottom, transparent, var(--overlay-blur-color, #060010))'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="viewerRef"
|
||||||
|
class="absolute inset-0 z-20 pointer-events-none flex items-center justify-center"
|
||||||
|
:style="{ padding: 'var(--viewer-pad)' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="scrimRef"
|
||||||
|
class="absolute inset-0 z-10 bg-black/40 pointer-events-none opacity-0 transition-opacity duration-500 ease-linear"
|
||||||
|
:style="{ backdropFilter: 'blur(3px)' }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref="frameRef"
|
||||||
|
class="h-full aspect-square flex max-[1/1]:h-auto max-[1/1]:w-full"
|
||||||
|
:style="{ borderRadius: 'var(--enlarge-radius, 32px)' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, computed, ref, useTemplateRef, watch } from 'vue';
|
||||||
|
|
||||||
|
interface ImageItem {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomeGalleryProps {
|
||||||
|
images?: (string | ImageItem)[];
|
||||||
|
fit?: number;
|
||||||
|
fitBasis?: 'auto' | 'min' | 'max' | 'width' | 'height';
|
||||||
|
minRadius?: number;
|
||||||
|
maxRadius?: number;
|
||||||
|
padFactor?: number;
|
||||||
|
overlayBlurColor?: string;
|
||||||
|
maxVerticalRotationDeg?: number;
|
||||||
|
dragSensitivity?: number;
|
||||||
|
enlargeTransitionMs?: number;
|
||||||
|
segments?: number;
|
||||||
|
dragDampening?: number;
|
||||||
|
openedImageWidth?: string;
|
||||||
|
openedImageHeight?: string;
|
||||||
|
imageBorderRadius?: string;
|
||||||
|
openedImageBorderRadius?: string;
|
||||||
|
grayscale?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_IMAGES: ImageItem[] = [
|
||||||
|
{
|
||||||
|
src: 'https://images.unsplash.com/photo-1755331039789-7e5680e26e8f?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||||
|
alt: 'Abstract art'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://images.unsplash.com/photo-1755569309049-98410b94f66d?q=80&w=772&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||||
|
alt: 'Modern sculpture'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://images.unsplash.com/photo-1755497595318-7e5e3523854f?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||||
|
alt: 'Digital artwork'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://images.unsplash.com/photo-1755353985163-c2a0fe5ac3d8?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||||
|
alt: 'Contemporary art'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://images.unsplash.com/photo-1745965976680-d00be7dc0377?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||||
|
alt: 'Geometric pattern'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://images.unsplash.com/photo-1752588975228-21f44630bb3c?q=80&w=774&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||||
|
alt: 'Textured surface'
|
||||||
|
},
|
||||||
|
{ src: 'https://pbs.twimg.com/media/Gyla7NnXMAAXSo_?format=jpg&name=large', alt: 'Social media image' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTO_ROTATE_SPEED_DEG_PER_MS = 0.008;
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DomeGalleryProps>(), {
|
||||||
|
fit: 0.5,
|
||||||
|
fitBasis: 'auto',
|
||||||
|
minRadius: 600,
|
||||||
|
maxRadius: Infinity,
|
||||||
|
padFactor: 0.25,
|
||||||
|
overlayBlurColor: '#060010',
|
||||||
|
maxVerticalRotationDeg: 5,
|
||||||
|
dragSensitivity: 20,
|
||||||
|
enlargeTransitionMs: 300,
|
||||||
|
segments: 35,
|
||||||
|
dragDampening: 2,
|
||||||
|
openedImageWidth: '400px',
|
||||||
|
openedImageHeight: '400px',
|
||||||
|
imageBorderRadius: '30px',
|
||||||
|
openedImageBorderRadius: '30px',
|
||||||
|
grayscale: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use computed to provide default images if none provided
|
||||||
|
const imagesSource = computed(() => props.images || DEFAULT_IMAGES);
|
||||||
|
|
||||||
|
// Template refs
|
||||||
|
const rootRef = useTemplateRef<HTMLDivElement>('rootRef');
|
||||||
|
const mainRef = useTemplateRef<HTMLElement>('mainRef');
|
||||||
|
const sphereRef = useTemplateRef<HTMLDivElement>('sphereRef');
|
||||||
|
const viewerRef = useTemplateRef<HTMLDivElement>('viewerRef');
|
||||||
|
const scrimRef = useTemplateRef<HTMLDivElement>('scrimRef');
|
||||||
|
const frameRef = useTemplateRef<HTMLDivElement>('frameRef');
|
||||||
|
|
||||||
|
// State refs
|
||||||
|
const rotation = ref({ x: 0, y: 0 });
|
||||||
|
const startRotation = ref({ x: 0, y: 0 });
|
||||||
|
const startPosition = ref<{ x: number; y: number } | null>(null);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const hasMoved = ref(false);
|
||||||
|
const isOpening = ref(false);
|
||||||
|
const focusedElement = ref<HTMLElement | null>(null);
|
||||||
|
const originalTilePosition = ref<DOMRect | null>(null);
|
||||||
|
const scrollLocked = ref(false);
|
||||||
|
const openStartedAt = ref(0);
|
||||||
|
const lastDragEndAt = ref(0);
|
||||||
|
|
||||||
|
let inertiaAnimationFrame: number | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let keydownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let autoRotateAnimationFrame: number | null = null;
|
||||||
|
let lastAutoRotateTime = 0;
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
const clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max);
|
||||||
|
const normalizeAngle = (d: number): number => ((d % 360) + 360) % 360;
|
||||||
|
const wrapAngleSigned = (deg: number): number => {
|
||||||
|
const a = (((deg + 180) % 360) + 360) % 360;
|
||||||
|
return a - 180;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataNumber = (el: HTMLElement, name: string, fallback: number): number => {
|
||||||
|
const attr = el.dataset[name] ?? el.getAttribute(`data-${name}`);
|
||||||
|
const n = attr == null ? NaN : parseFloat(attr);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build items function
|
||||||
|
function buildItems(pool: (string | ImageItem)[], seg: number) {
|
||||||
|
const xCols = Array.from({ length: seg }, (_, i) => -37 + i * 2);
|
||||||
|
const evenYs = [-4, -2, 0, 2, 4];
|
||||||
|
const oddYs = [-3, -1, 1, 3, 5];
|
||||||
|
|
||||||
|
const coords = xCols.flatMap((x, c) => {
|
||||||
|
const ys = c % 2 === 0 ? evenYs : oddYs;
|
||||||
|
return ys.map(y => ({ x, y, sizeX: 2, sizeY: 2 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSlots = coords.length;
|
||||||
|
if (pool.length === 0) {
|
||||||
|
return coords.map(c => ({ ...c, src: '', alt: '' }));
|
||||||
|
}
|
||||||
|
if (pool.length > totalSlots) {
|
||||||
|
console.warn(
|
||||||
|
`[DomeGallery] Provided image count (${pool.length}) exceeds available tiles (${totalSlots}). Some images will not be shown.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedImages = pool.map(image => {
|
||||||
|
if (typeof image === 'string') {
|
||||||
|
return { src: image, alt: '' };
|
||||||
|
}
|
||||||
|
return { src: image.src || '', alt: image.alt || '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedImages = Array.from({ length: totalSlots }, (_, i) => normalizedImages[i % normalizedImages.length]);
|
||||||
|
|
||||||
|
// Shuffle to avoid adjacent duplicates
|
||||||
|
for (let i = 1; i < usedImages.length; i++) {
|
||||||
|
if (usedImages[i].src === usedImages[i - 1].src) {
|
||||||
|
for (let j = i + 1; j < usedImages.length; j++) {
|
||||||
|
if (usedImages[j].src !== usedImages[i].src) {
|
||||||
|
const tmp = usedImages[i];
|
||||||
|
usedImages[i] = usedImages[j];
|
||||||
|
usedImages[j] = tmp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return coords.map((c, i) => ({
|
||||||
|
...c,
|
||||||
|
src: usedImages[i].src,
|
||||||
|
alt: usedImages[i].alt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute items
|
||||||
|
const items = computed(() => buildItems(imagesSource.value, props.segments));
|
||||||
|
|
||||||
|
// Compute item base rotation
|
||||||
|
function computeItemBaseRotation(offsetX: number, offsetY: number, sizeX: number, sizeY: number, segments: number) {
|
||||||
|
const unit = 360 / segments / 2;
|
||||||
|
const rotateY = unit * (offsetX + (sizeX - 1) / 2);
|
||||||
|
const rotateX = unit * (offsetY - (sizeY - 1) / 2);
|
||||||
|
return { rotateX, rotateY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply transform
|
||||||
|
const applyTransform = (xDeg: number, yDeg: number) => {
|
||||||
|
const el = sphereRef.value;
|
||||||
|
if (el) {
|
||||||
|
el.style.transform = `translateZ(calc(var(--radius) * -1)) rotateX(${xDeg}deg) rotateY(${yDeg}deg)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll lock functions
|
||||||
|
const lockScroll = () => {
|
||||||
|
if (scrollLocked.value) return;
|
||||||
|
scrollLocked.value = true;
|
||||||
|
document.body.classList.add('overflow-hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockScroll = () => {
|
||||||
|
if (!scrollLocked.value) return;
|
||||||
|
if (rootRef.value?.getAttribute('data-enlarging') === 'true') return;
|
||||||
|
scrollLocked.value = false;
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inertia functions
|
||||||
|
const stopInertia = () => {
|
||||||
|
if (inertiaAnimationFrame) {
|
||||||
|
cancelAnimationFrame(inertiaAnimationFrame);
|
||||||
|
inertiaAnimationFrame = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startInertia = (vx: number, vy: number) => {
|
||||||
|
const MAX_V = 1.4;
|
||||||
|
let vX = clamp(vx, -MAX_V, MAX_V) * 80;
|
||||||
|
let vY = clamp(vy, -MAX_V, MAX_V) * 80;
|
||||||
|
let frames = 0;
|
||||||
|
const d = clamp(props.dragDampening ?? 0.6, 0, 1);
|
||||||
|
const frictionMul = 0.94 + 0.055 * d;
|
||||||
|
const stopThreshold = 0.015 - 0.01 * d;
|
||||||
|
const maxFrames = Math.round(90 + 270 * d);
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
vX *= frictionMul;
|
||||||
|
vY *= frictionMul;
|
||||||
|
if (Math.abs(vX) < stopThreshold && Math.abs(vY) < stopThreshold) {
|
||||||
|
inertiaAnimationFrame = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (++frames > maxFrames) {
|
||||||
|
inertiaAnimationFrame = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextX = clamp(rotation.value.x - vY / 200, -props.maxVerticalRotationDeg, props.maxVerticalRotationDeg);
|
||||||
|
const nextY = wrapAngleSigned(rotation.value.y + vX / 200);
|
||||||
|
rotation.value = { x: nextX, y: nextY };
|
||||||
|
applyTransform(nextX, nextY);
|
||||||
|
inertiaAnimationFrame = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
stopInertia();
|
||||||
|
inertiaAnimationFrame = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRotate = () => {
|
||||||
|
if (autoRotateAnimationFrame) {
|
||||||
|
cancelAnimationFrame(autoRotateAnimationFrame);
|
||||||
|
autoRotateAnimationFrame = null;
|
||||||
|
}
|
||||||
|
lastAutoRotateTime = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoRotateStep = (now: number) => {
|
||||||
|
if (!lastAutoRotateTime) {
|
||||||
|
lastAutoRotateTime = now;
|
||||||
|
}
|
||||||
|
const deltaMs = now - lastAutoRotateTime;
|
||||||
|
lastAutoRotateTime = now;
|
||||||
|
|
||||||
|
const canSpin =
|
||||||
|
!isDragging.value &&
|
||||||
|
!isOpening.value &&
|
||||||
|
!focusedElement.value &&
|
||||||
|
inertiaAnimationFrame === null;
|
||||||
|
|
||||||
|
if (canSpin && deltaMs > 0) {
|
||||||
|
const nextY = wrapAngleSigned(rotation.value.y + deltaMs * AUTO_ROTATE_SPEED_DEG_PER_MS);
|
||||||
|
if (nextY !== rotation.value.y) {
|
||||||
|
rotation.value = { x: rotation.value.x, y: nextY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
autoRotateAnimationFrame = requestAnimationFrame(autoRotateStep);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoRotate = () => {
|
||||||
|
if (autoRotateAnimationFrame !== null) return;
|
||||||
|
lastAutoRotateTime = 0;
|
||||||
|
autoRotateAnimationFrame = requestAnimationFrame(autoRotateStep);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gesture handling
|
||||||
|
const onDragStart = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (focusedElement.value) return;
|
||||||
|
stopInertia();
|
||||||
|
|
||||||
|
isDragging.value = true;
|
||||||
|
hasMoved.value = false;
|
||||||
|
startRotation.value = { ...rotation.value };
|
||||||
|
|
||||||
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||||
|
startPosition.value = { x: clientX, y: clientY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragMove = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (focusedElement.value || !isDragging.value || !startPosition.value) return;
|
||||||
|
|
||||||
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||||
|
|
||||||
|
const dxTotal = clientX - startPosition.value.x;
|
||||||
|
const dyTotal = clientY - startPosition.value.y;
|
||||||
|
|
||||||
|
if (!hasMoved.value) {
|
||||||
|
const dist2 = dxTotal * dxTotal + dyTotal * dyTotal;
|
||||||
|
if (dist2 > 16) hasMoved.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextX = clamp(
|
||||||
|
startRotation.value.x - dyTotal / props.dragSensitivity,
|
||||||
|
-props.maxVerticalRotationDeg,
|
||||||
|
props.maxVerticalRotationDeg
|
||||||
|
);
|
||||||
|
const nextY = wrapAngleSigned(startRotation.value.y + dxTotal / props.dragSensitivity);
|
||||||
|
|
||||||
|
if (rotation.value.x !== nextX || rotation.value.y !== nextY) {
|
||||||
|
rotation.value = { x: nextX, y: nextY };
|
||||||
|
applyTransform(nextX, nextY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
|
||||||
|
isDragging.value = false;
|
||||||
|
|
||||||
|
// Calculate velocity for inertia (simplified version)
|
||||||
|
if (hasMoved.value && startPosition.value) {
|
||||||
|
const clientX = 'touches' in e ? e.changedTouches?.[0]?.clientX ?? 0 : e.clientX;
|
||||||
|
const clientY = 'touches' in e ? e.changedTouches?.[0]?.clientY ?? 0 : e.clientY;
|
||||||
|
|
||||||
|
const dxTotal = clientX - startPosition.value.x;
|
||||||
|
const dyTotal = clientY - startPosition.value.y;
|
||||||
|
|
||||||
|
// Simple velocity calculation based on total movement
|
||||||
|
const vx = clamp((dxTotal / props.dragSensitivity) * 0.02, -1.2, 1.2);
|
||||||
|
const vy = clamp((dyTotal / props.dragSensitivity) * 0.02, -1.2, 1.2);
|
||||||
|
|
||||||
|
if (Math.abs(vx) > 0.005 || Math.abs(vy) > 0.005) {
|
||||||
|
startInertia(vx, vy);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDragEndAt.value = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMoved.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image enlargement functionality
|
||||||
|
const openItemFromElement = (el: HTMLElement) => {
|
||||||
|
if (isOpening.value) return;
|
||||||
|
isOpening.value = true;
|
||||||
|
openStartedAt.value = performance.now();
|
||||||
|
lockScroll();
|
||||||
|
|
||||||
|
const parent = el.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
focusedElement.value = el;
|
||||||
|
el.setAttribute('data-focused', 'true');
|
||||||
|
|
||||||
|
const offsetX = getDataNumber(parent, 'offsetX', 0);
|
||||||
|
const offsetY = getDataNumber(parent, 'offsetY', 0);
|
||||||
|
const sizeX = getDataNumber(parent, 'sizeX', 2);
|
||||||
|
const sizeY = getDataNumber(parent, 'sizeY', 2);
|
||||||
|
|
||||||
|
const parentRot = computeItemBaseRotation(offsetX, offsetY, sizeX, sizeY, props.segments);
|
||||||
|
const parentY = normalizeAngle(parentRot.rotateY);
|
||||||
|
const globalY = normalizeAngle(rotation.value.y);
|
||||||
|
let rotY = -(parentY + globalY) % 360;
|
||||||
|
if (rotY < -180) rotY += 360;
|
||||||
|
const rotX = -parentRot.rotateX - rotation.value.x;
|
||||||
|
|
||||||
|
parent.style.setProperty('--rot-y-delta', `${rotY}deg`);
|
||||||
|
parent.style.setProperty('--rot-x-delta', `${rotX}deg`);
|
||||||
|
|
||||||
|
const refDiv = document.createElement('div');
|
||||||
|
refDiv.className = 'item__image item__image--reference';
|
||||||
|
refDiv.style.opacity = '0';
|
||||||
|
refDiv.style.transform = `rotateX(${-parentRot.rotateX}deg) rotateY(${-parentRot.rotateY}deg)`;
|
||||||
|
parent.appendChild(refDiv);
|
||||||
|
|
||||||
|
const tileR = refDiv.getBoundingClientRect();
|
||||||
|
const mainR = mainRef.value?.getBoundingClientRect();
|
||||||
|
const frameR = frameRef.value?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!mainR || !frameR) return;
|
||||||
|
|
||||||
|
originalTilePosition.value = {
|
||||||
|
left: tileR.left,
|
||||||
|
top: tileR.top,
|
||||||
|
width: tileR.width,
|
||||||
|
height: tileR.height
|
||||||
|
} as DOMRect;
|
||||||
|
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
el.style.zIndex = '0';
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'enlarge';
|
||||||
|
overlay.style.position = 'absolute';
|
||||||
|
overlay.style.left = `${frameR.left - mainR.left}px`;
|
||||||
|
overlay.style.top = `${frameR.top - mainR.top}px`;
|
||||||
|
overlay.style.width = `${frameR.width}px`;
|
||||||
|
overlay.style.height = `${frameR.height}px`;
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
overlay.style.zIndex = '30';
|
||||||
|
overlay.style.willChange = 'transform, opacity';
|
||||||
|
overlay.style.transformOrigin = 'top left';
|
||||||
|
overlay.style.transition = `transform ${props.enlargeTransitionMs}ms ease, opacity ${props.enlargeTransitionMs}ms ease`;
|
||||||
|
|
||||||
|
const rawSrc = parent.dataset.src || el.querySelector('img')?.src || '';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = rawSrc;
|
||||||
|
overlay.appendChild(img);
|
||||||
|
viewerRef.value?.appendChild(overlay);
|
||||||
|
|
||||||
|
const tx0 = tileR.left - frameR.left;
|
||||||
|
const ty0 = tileR.top - frameR.top;
|
||||||
|
const sx0 = tileR.width / frameR.width;
|
||||||
|
const sy0 = tileR.height / frameR.height;
|
||||||
|
overlay.style.transform = `translate(${tx0}px, ${ty0}px) scale(${sx0}, ${sy0})`;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.style.opacity = '1';
|
||||||
|
overlay.style.transform = 'translate(0px, 0px) scale(1,1)';
|
||||||
|
rootRef.value?.setAttribute('data-enlarging', 'true');
|
||||||
|
scrimRef.value?.classList.add('opacity-100', 'pointer-events-auto');
|
||||||
|
scrimRef.value?.classList.remove('opacity-0', 'pointer-events-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
const wantsResize = props.openedImageWidth || props.openedImageHeight;
|
||||||
|
if (wantsResize) {
|
||||||
|
const onFirstEnd = (ev: TransitionEvent) => {
|
||||||
|
if (ev.propertyName !== 'transform') return;
|
||||||
|
overlay.removeEventListener('transitionend', onFirstEnd);
|
||||||
|
const prevTransition = overlay.style.transition;
|
||||||
|
overlay.style.transition = 'none';
|
||||||
|
const tempWidth = props.openedImageWidth || `${frameR.width}px`;
|
||||||
|
const tempHeight = props.openedImageHeight || `${frameR.height}px`;
|
||||||
|
overlay.style.width = tempWidth;
|
||||||
|
overlay.style.height = tempHeight;
|
||||||
|
const newRect = overlay.getBoundingClientRect();
|
||||||
|
overlay.style.width = `${frameR.width}px`;
|
||||||
|
overlay.style.height = `${frameR.height}px`;
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.style.transition = `left ${props.enlargeTransitionMs}ms ease, top ${props.enlargeTransitionMs}ms ease, width ${props.enlargeTransitionMs}ms ease, height ${props.enlargeTransitionMs}ms ease`;
|
||||||
|
const centeredLeft = frameR.left - mainR.left + (frameR.width - newRect.width) / 2;
|
||||||
|
const centeredTop = frameR.top - mainR.top + (frameR.height - newRect.height) / 2;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.style.left = `${centeredLeft}px`;
|
||||||
|
overlay.style.top = `${centeredTop}px`;
|
||||||
|
overlay.style.width = tempWidth;
|
||||||
|
overlay.style.height = tempHeight;
|
||||||
|
});
|
||||||
|
const cleanupSecond = () => {
|
||||||
|
overlay.removeEventListener('transitionend', cleanupSecond);
|
||||||
|
overlay.style.transition = prevTransition;
|
||||||
|
};
|
||||||
|
overlay.addEventListener('transitionend', cleanupSecond, { once: true });
|
||||||
|
};
|
||||||
|
overlay.addEventListener('transitionend', onFirstEnd);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEnlargedImage = () => {
|
||||||
|
if (performance.now() - openStartedAt.value < 250) return;
|
||||||
|
const el = focusedElement.value;
|
||||||
|
if (!el) return;
|
||||||
|
const parent = el.parentElement;
|
||||||
|
const overlay = viewerRef.value?.querySelector('.enlarge') as HTMLElement;
|
||||||
|
if (!overlay || !parent) return;
|
||||||
|
const refDiv = parent.querySelector('.item__image--reference');
|
||||||
|
const originalPos = originalTilePosition.value;
|
||||||
|
|
||||||
|
if (!originalPos) {
|
||||||
|
overlay.remove();
|
||||||
|
if (refDiv) refDiv.remove();
|
||||||
|
parent.style.setProperty('--rot-y-delta', '0deg');
|
||||||
|
parent.style.setProperty('--rot-x-delta', '0deg');
|
||||||
|
el.style.visibility = '';
|
||||||
|
el.style.zIndex = '0';
|
||||||
|
focusedElement.value = null;
|
||||||
|
rootRef.value?.removeAttribute('data-enlarging');
|
||||||
|
scrimRef.value?.classList.add('opacity-0', 'pointer-events-none');
|
||||||
|
scrimRef.value?.classList.remove('opacity-100', 'pointer-events-auto');
|
||||||
|
isOpening.value = false;
|
||||||
|
unlockScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRect = overlay.getBoundingClientRect();
|
||||||
|
const rootRect = rootRef.value?.getBoundingClientRect();
|
||||||
|
if (!rootRect) return;
|
||||||
|
|
||||||
|
const originalPosRelativeToRoot = {
|
||||||
|
left: originalPos.left - rootRect.left,
|
||||||
|
top: originalPos.top - rootRect.top,
|
||||||
|
width: originalPos.width,
|
||||||
|
height: originalPos.height
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayRelativeToRoot = {
|
||||||
|
left: currentRect.left - rootRect.left,
|
||||||
|
top: currentRect.top - rootRect.top,
|
||||||
|
width: currentRect.width,
|
||||||
|
height: currentRect.height
|
||||||
|
};
|
||||||
|
|
||||||
|
const animatingOverlay = document.createElement('div');
|
||||||
|
animatingOverlay.className = 'enlarge-closing';
|
||||||
|
animatingOverlay.style.cssText = `position:absolute;left:${overlayRelativeToRoot.left}px;top:${overlayRelativeToRoot.top}px;width:${overlayRelativeToRoot.width}px;height:${overlayRelativeToRoot.height}px;z-index:9999;border-radius: var(--enlarge-radius, 32px);overflow:hidden;box-shadow:0 10px 30px rgba(0,0,0,.35);transition:all ${props.enlargeTransitionMs}ms ease-out;pointer-events:none;margin:0;transform:none;`;
|
||||||
|
|
||||||
|
const originalImg = overlay.querySelector('img');
|
||||||
|
if (originalImg) {
|
||||||
|
const img = originalImg.cloneNode() as HTMLImageElement;
|
||||||
|
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
|
||||||
|
animatingOverlay.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.remove();
|
||||||
|
rootRef.value?.appendChild(animatingOverlay);
|
||||||
|
void animatingOverlay.getBoundingClientRect();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
animatingOverlay.style.left = `${originalPosRelativeToRoot.left}px`;
|
||||||
|
animatingOverlay.style.top = `${originalPosRelativeToRoot.top}px`;
|
||||||
|
animatingOverlay.style.width = `${originalPosRelativeToRoot.width}px`;
|
||||||
|
animatingOverlay.style.height = `${originalPosRelativeToRoot.height}px`;
|
||||||
|
animatingOverlay.style.opacity = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
animatingOverlay.remove();
|
||||||
|
originalTilePosition.value = null;
|
||||||
|
if (refDiv) refDiv.remove();
|
||||||
|
parent.style.transition = 'none';
|
||||||
|
el.style.transition = 'none';
|
||||||
|
parent.style.setProperty('--rot-y-delta', '0deg');
|
||||||
|
parent.style.setProperty('--rot-x-delta', '0deg');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.visibility = '';
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.zIndex = '0';
|
||||||
|
focusedElement.value = null;
|
||||||
|
rootRef.value?.removeAttribute('data-enlarging');
|
||||||
|
scrimRef.value?.classList.add('opacity-0', 'pointer-events-none');
|
||||||
|
scrimRef.value?.classList.remove('opacity-100', 'pointer-events-auto');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
parent.style.transition = '';
|
||||||
|
el.style.transition = 'opacity 300ms ease-out';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.transition = '';
|
||||||
|
el.style.opacity = '';
|
||||||
|
isOpening.value = false;
|
||||||
|
unlockScroll();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
animatingOverlay.addEventListener('transitionend', cleanup, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event handlers for tile interaction
|
||||||
|
const onTileClick = (e: Event) => {
|
||||||
|
if (isDragging.value) return;
|
||||||
|
if (performance.now() - lastDragEndAt.value < 80) return;
|
||||||
|
if (isOpening.value) return;
|
||||||
|
openItemFromElement(e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTilePointerUp = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType !== 'touch') return;
|
||||||
|
if (isDragging.value) return;
|
||||||
|
if (performance.now() - lastDragEndAt.value < 80) return;
|
||||||
|
if (isOpening.value) return;
|
||||||
|
openItemFromElement(e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTileTouchEnd = (e: TouchEvent) => {
|
||||||
|
if (isDragging.value) return;
|
||||||
|
if (performance.now() - lastDragEndAt.value < 80) return;
|
||||||
|
if (isOpening.value) return;
|
||||||
|
openItemFromElement(e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup ResizeObserver and event listeners
|
||||||
|
onMounted(() => {
|
||||||
|
// Initialize transform
|
||||||
|
applyTransform(rotation.value.x, rotation.value.y);
|
||||||
|
startAutoRotate();
|
||||||
|
|
||||||
|
// Setup ResizeObserver
|
||||||
|
const root = rootRef.value;
|
||||||
|
const main = mainRef.value;
|
||||||
|
if (!root || !main) return;
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
|
const cr = entries[0].contentRect;
|
||||||
|
const w = Math.max(1, cr.width);
|
||||||
|
const h = Math.max(1, cr.height);
|
||||||
|
const minDim = Math.min(w, h);
|
||||||
|
const maxDim = Math.max(w, h);
|
||||||
|
const aspect = w / h;
|
||||||
|
|
||||||
|
let basis: number;
|
||||||
|
switch (props.fitBasis) {
|
||||||
|
case 'min':
|
||||||
|
basis = minDim;
|
||||||
|
break;
|
||||||
|
case 'max':
|
||||||
|
basis = maxDim;
|
||||||
|
break;
|
||||||
|
case 'width':
|
||||||
|
basis = w;
|
||||||
|
break;
|
||||||
|
case 'height':
|
||||||
|
basis = h;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
basis = aspect >= 1.3 ? w : minDim;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = basis * props.fit;
|
||||||
|
const heightGuard = h * 1.35;
|
||||||
|
radius = Math.min(radius, heightGuard);
|
||||||
|
radius = clamp(radius, props.minRadius, props.maxRadius);
|
||||||
|
|
||||||
|
const viewerPad = Math.max(8, Math.round(minDim * props.padFactor));
|
||||||
|
const roundedRadius = Math.round(radius);
|
||||||
|
|
||||||
|
root.style.setProperty('--radius', `${roundedRadius}px`);
|
||||||
|
root.style.setProperty('--viewer-pad', `${viewerPad}px`);
|
||||||
|
|
||||||
|
const overlay = viewerRef.value?.querySelector('.enlarge') as HTMLElement | null;
|
||||||
|
if (overlay && frameRef.value && mainRef.value) {
|
||||||
|
const frameR = frameRef.value.getBoundingClientRect();
|
||||||
|
const mainR = mainRef.value.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (props.openedImageWidth && props.openedImageHeight) {
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.style.cssText = `position:absolute;visibility:hidden;width:${props.openedImageWidth};height:${props.openedImageHeight};pointer-events:none;`;
|
||||||
|
document.body.appendChild(tempDiv);
|
||||||
|
const tempRect = tempDiv.getBoundingClientRect();
|
||||||
|
document.body.removeChild(tempDiv);
|
||||||
|
|
||||||
|
const centeredLeft = frameR.left - mainR.left + (frameR.width - tempRect.width) / 2;
|
||||||
|
const centeredTop = frameR.top - mainR.top + (frameR.height - tempRect.height) / 2;
|
||||||
|
overlay.style.left = `${centeredLeft}px`;
|
||||||
|
overlay.style.top = `${centeredTop}px`;
|
||||||
|
overlay.style.width = props.openedImageWidth;
|
||||||
|
overlay.style.height = props.openedImageHeight;
|
||||||
|
} else {
|
||||||
|
overlay.style.left = `${frameR.left - mainR.left}px`;
|
||||||
|
overlay.style.top = `${frameR.top - mainR.top}px`;
|
||||||
|
overlay.style.width = `${frameR.width}px`;
|
||||||
|
overlay.style.height = `${frameR.height}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(root);
|
||||||
|
|
||||||
|
// Add gesture event listeners
|
||||||
|
main.addEventListener('mousedown', onDragStart, { passive: true });
|
||||||
|
main.addEventListener('touchstart', onDragStart, { passive: true });
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onDragMove, { passive: true });
|
||||||
|
window.addEventListener('touchmove', onDragMove, { passive: true });
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', onDragEnd);
|
||||||
|
window.addEventListener('touchend', onDragEnd);
|
||||||
|
|
||||||
|
// Add enlargement event listeners
|
||||||
|
const scrim = scrimRef.value;
|
||||||
|
if (scrim) {
|
||||||
|
scrim.addEventListener('click', closeEnlargedImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
keydownHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeEnlargedImage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', keydownHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopInertia();
|
||||||
|
stopAutoRotate();
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
const main = mainRef.value;
|
||||||
|
const scrim = scrimRef.value;
|
||||||
|
|
||||||
|
if (main) {
|
||||||
|
main.removeEventListener('mousedown', onDragStart);
|
||||||
|
main.removeEventListener('touchstart', onDragStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrim) {
|
||||||
|
scrim.removeEventListener('click', closeEnlargedImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('mousemove', onDragMove);
|
||||||
|
window.removeEventListener('touchmove', onDragMove);
|
||||||
|
window.removeEventListener('mouseup', onDragEnd);
|
||||||
|
window.removeEventListener('touchend', onDragEnd);
|
||||||
|
|
||||||
|
if (keydownHandler) {
|
||||||
|
window.removeEventListener('keydown', keydownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for rotation changes
|
||||||
|
watch(rotation, (newRotation) => {
|
||||||
|
applyTransform(newRotation.x, newRotation.y);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -31,26 +31,6 @@ const focusRect = ref({ x: 0, y: 0, width: 0, height: 0 });
|
|||||||
|
|
||||||
let interval: number | null = null;
|
let interval: number | null = null;
|
||||||
|
|
||||||
watch(
|
|
||||||
[() => props.manualMode, () => props.animationDuration, () => props.pauseBetweenAnimations, () => words.value],
|
|
||||||
() => {
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
interval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.manualMode) {
|
|
||||||
interval = setInterval(
|
|
||||||
() => {
|
|
||||||
currentIndex.value = (currentIndex.value + 1) % words.value.length;
|
|
||||||
},
|
|
||||||
(props.animationDuration + props.pauseBetweenAnimations) * 1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[currentIndex, () => words.value.length],
|
[currentIndex, () => words.value.length],
|
||||||
async () => {
|
async () => {
|
||||||
@@ -105,6 +85,26 @@ onMounted(async () => {
|
|||||||
height: activeRect.height
|
height: activeRect.height
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.manualMode, () => props.animationDuration, () => props.pauseBetweenAnimations, () => words.value],
|
||||||
|
() => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
interval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.manualMode) {
|
||||||
|
interval = setInterval(
|
||||||
|
() => {
|
||||||
|
currentIndex.value = (currentIndex.value + 1) % words.value.length;
|
||||||
|
},
|
||||||
|
(props.animationDuration + props.pauseBetweenAnimations) * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
213
src/demo/Animations/LaserFlowDemo.vue
Normal file
213
src/demo/Animations/LaserFlowDemo.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div
|
||||||
|
class="relative w-full h-[600px] overflow-hidden demo-container"
|
||||||
|
@mousemove="handleMouseMove"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
ref="containerRef"
|
||||||
|
>
|
||||||
|
<RefreshButton @click="forceRerender" />
|
||||||
|
|
||||||
|
<LaserFlow
|
||||||
|
:horizontal-beam-offset="selectedExample === 'box' ? 0.1 : 0.0"
|
||||||
|
:vertical-beam-offset="selectedExample === 'box' ? -0.2 : -0.5"
|
||||||
|
:horizontal-sizing="horizontalSizing"
|
||||||
|
:vertical-sizing="verticalSizing"
|
||||||
|
:wisp-density="wispDensity"
|
||||||
|
:wisp-speed="wispSpeed"
|
||||||
|
:wisp-intensity="wispIntensity"
|
||||||
|
:flow-speed="flowSpeed"
|
||||||
|
:flow-strength="flowStrength"
|
||||||
|
:fog-intensity="fogIntensity"
|
||||||
|
:fog-scale="fogScale"
|
||||||
|
:fog-fall-speed="fogFallSpeed"
|
||||||
|
:decay="decay"
|
||||||
|
:dpr="1"
|
||||||
|
:falloff-start="falloffStart"
|
||||||
|
:color="laserColor"
|
||||||
|
:key="key"
|
||||||
|
:class-name="`laser-flow-demo-${selectedExample}`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="selectedExample === 'box'">
|
||||||
|
<div
|
||||||
|
class="top-[70%] left-1/2 z-[6] absolute flex justify-center items-center bg-black border-2 rounded-[20px] w-[86%] h-[60%] text-white text-2xl -translate-x-1/2"
|
||||||
|
:style="{
|
||||||
|
borderColor: laserColor,
|
||||||
|
backgroundImage: 'radial-gradient(circle, #165f2b 1px, transparent 1px)',
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<img
|
||||||
|
ref="revealImgRef"
|
||||||
|
src="https://cdn.dribbble.com/userupload/15325964/file/original-25ae735b5d9255a4a31d3471fd1c346a.png?resize=1024x768&vertical=center"
|
||||||
|
class="-top-1/2 z-2 absolute opacity-30 w-full pointer-events-none mix-blend-lighten"
|
||||||
|
:style="{
|
||||||
|
['--mx']: '-9999px',
|
||||||
|
['--my']: '-9999px',
|
||||||
|
WebkitMaskImage:
|
||||||
|
'radial-gradient(circle at var(--mx) var(--my), rgba(255,255,255,1) 0px, rgba(255,255,255,0.95) 60px, rgba(255,255,255,0.6) 120px, rgba(255,255,255,0.25) 180px, rgba(255,255,255,0) 240px)',
|
||||||
|
maskImage:
|
||||||
|
'radial-gradient(circle at var(--mx) var(--my), rgba(255,255,255,1) 0px, rgba(255,255,255,0.95) 60px, rgba(255,255,255,0.6) 120px, rgba(255,255,255,0.25) 180px, rgba(255,255,255,0) 240px)',
|
||||||
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
|
maskRepeat: 'no-repeat'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSelect title="Example:" v-model="selectedExample" :options="exampleOptions" />
|
||||||
|
<PreviewColor title="Color" v-model="laserColor" />
|
||||||
|
<PreviewSlider title="Horizontal Sizing" :min="0.1" :max="2" :step="0.01" v-model="horizontalSizing" />
|
||||||
|
<PreviewSlider title="Vertical Sizing" :min="0.1" :max="5" :step="0.1" v-model="verticalSizing" />
|
||||||
|
<PreviewSlider title="Wisp Density" :min="0" :max="5" :step="0.1" v-model="wispDensity" />
|
||||||
|
<PreviewSlider title="Wisp Speed" :min="1" :max="50" :step="0.5" v-model="wispSpeed" />
|
||||||
|
<PreviewSlider title="Wisp Intensity" :min="0" :max="20" :step="0.1" v-model="wispIntensity" />
|
||||||
|
<PreviewSlider title="Flow Speed" :min="0" :max="2" :step="0.02" v-model="flowSpeed" />
|
||||||
|
<PreviewSlider title="Flow Strength" :min="0" :max="1" :step="0.01" v-model="flowStrength" />
|
||||||
|
<PreviewSlider title="Fog Intensity" :min="0" :max="1" :step="0.01" v-model="fogIntensity" />
|
||||||
|
<PreviewSlider title="Fog Scale" :min="0.1" :max="1" :step="0.01" v-model="fogScale" />
|
||||||
|
<PreviewSlider title="Fog Fall Speed" :min="0" :max="2" :step="0.01" v-model="fogFallSpeed" />
|
||||||
|
<PreviewSlider title="Decay" :min="0.5" :max="3" :step="0.01" v-model="decay" />
|
||||||
|
<PreviewSlider title="Falloff Start" :min="0.5" :max="3" :step="0.01" v-model="falloffStart" />
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
<Dependencies :dependency-list="['three']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="laserFlow" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="laserFlow.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender';
|
||||||
|
import { laserFlow } from '@/constants/code/Animations/laserFlowCode';
|
||||||
|
import { ref, useTemplateRef } from 'vue';
|
||||||
|
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||||
|
import CodeExample from '../../components/code/CodeExample.vue';
|
||||||
|
import Dependencies from '../../components/code/Dependencies.vue';
|
||||||
|
import Customize from '../../components/common/Customize.vue';
|
||||||
|
import PreviewColor from '../../components/common/PreviewColor.vue';
|
||||||
|
import PreviewSelect from '../../components/common/PreviewSelect.vue';
|
||||||
|
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||||
|
import PropTable from '../../components/common/PropTable.vue';
|
||||||
|
import RefreshButton from '../../components/common/RefreshButton.vue';
|
||||||
|
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||||
|
import LaserFlow from '../../content/Animations/LaserFlow/LaserFlow.vue';
|
||||||
|
|
||||||
|
const { rerenderKey: key, forceRerender } = useForceRerender();
|
||||||
|
|
||||||
|
type ExampleKey = 'box' | 'basic';
|
||||||
|
|
||||||
|
const exampleOptions = [
|
||||||
|
{ label: 'Box', value: 'box' },
|
||||||
|
{ label: 'Basic', value: 'basic' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerRef = useTemplateRef('containerRef');
|
||||||
|
const revealImgRef = useTemplateRef('revealImgRef');
|
||||||
|
const selectedExample = ref<ExampleKey>('box');
|
||||||
|
const laserColor = ref('#A0FFBC');
|
||||||
|
const horizontalSizing = ref(0.5);
|
||||||
|
const verticalSizing = ref(2.0);
|
||||||
|
const wispDensity = ref(1);
|
||||||
|
const wispSpeed = ref(15.0);
|
||||||
|
const wispIntensity = ref(5.0);
|
||||||
|
const flowSpeed = ref(0.35);
|
||||||
|
const flowStrength = ref(0.25);
|
||||||
|
const fogIntensity = ref(0.45);
|
||||||
|
const fogScale = ref(0.3);
|
||||||
|
const fogFallSpeed = ref(0.6);
|
||||||
|
const decay = ref(1.1);
|
||||||
|
const falloffStart = ref(1.2);
|
||||||
|
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const el = revealImgRef.value;
|
||||||
|
if (el) {
|
||||||
|
el.style.setProperty('--mx', `${x}px`);
|
||||||
|
el.style.setProperty('--my', `${y + rect.height * 0.5}px`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
const el = revealImgRef.value;
|
||||||
|
if (el) {
|
||||||
|
el.style.setProperty('--mx', `-9999px`);
|
||||||
|
el.style.setProperty('--my', `-9999px`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'horizontalBeamOffset',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.1',
|
||||||
|
description: 'Horizontal offset of the beam (0–1 of canvas width).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'verticalBeamOffset',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.0',
|
||||||
|
description: 'Vertical offset of the beam (0–1 of canvas height).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'horizontalSizing',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.5',
|
||||||
|
description: 'Horizontal sizing factor of the beam footprint.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'verticalSizing',
|
||||||
|
type: 'number',
|
||||||
|
default: '2.0',
|
||||||
|
description: 'Vertical sizing factor of the beam footprint.'
|
||||||
|
},
|
||||||
|
{ name: 'wispDensity', type: 'number', default: '1', description: 'Density of micro-streak wisps.' },
|
||||||
|
{ name: 'wispSpeed', type: 'number', default: '15.0', description: 'Speed of wisp motion.' },
|
||||||
|
{ name: 'wispIntensity', type: 'number', default: '5.0', description: 'Brightness of wisps.' },
|
||||||
|
{ name: 'flowSpeed', type: 'number', default: '0.35', description: 'Speed of the beam’s flow modulation.' },
|
||||||
|
{ name: 'flowStrength', type: 'number', default: '0.25', description: 'Strength of the beam’s flow modulation.' },
|
||||||
|
{ name: 'fogIntensity', type: 'number', default: '0.45', description: 'Overall volumetric fog intensity.' },
|
||||||
|
{ name: 'fogScale', type: 'number', default: '0.3', description: 'Spatial scale for the fog noise.' },
|
||||||
|
{ name: 'fogFallSpeed', type: 'number', default: '0.6', description: 'Drift speed for the fog field.' },
|
||||||
|
{
|
||||||
|
name: 'mouseTiltStrength',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.01',
|
||||||
|
description: 'How much mouse x tilts the fog volume.'
|
||||||
|
},
|
||||||
|
{ name: 'mouseSmoothTime', type: 'number', default: '0.0', description: 'Pointer smoothing time (seconds).' },
|
||||||
|
{ name: 'decay', type: 'number', default: '1.1', description: 'Beam decay shaping for sampling envelope.' },
|
||||||
|
{
|
||||||
|
name: 'falloffStart',
|
||||||
|
type: 'number',
|
||||||
|
default: '1.2',
|
||||||
|
description: 'Falloff start radius used in inverse-square blending.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dpr',
|
||||||
|
type: 'number',
|
||||||
|
default: 'auto',
|
||||||
|
description: 'Device pixel ratio override (defaults to window.devicePixelRatio).'
|
||||||
|
},
|
||||||
|
{ name: 'color', type: 'string', default: '#A0FFBC', description: 'Beam color (hex).' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.demo-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
221
src/demo/Components/DomeGalleryDemo.vue
Normal file
221
src/demo/Components/DomeGalleryDemo.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container h-[600px]">
|
||||||
|
<DomeGallery
|
||||||
|
:key="rerenderKey"
|
||||||
|
:fit="fit"
|
||||||
|
:fit-basis="fitBasis"
|
||||||
|
:min-radius="minRadius"
|
||||||
|
:max-radius="maxRadius"
|
||||||
|
:pad-factor="padFactor"
|
||||||
|
:overlay-blur-color="overlayBlurColor"
|
||||||
|
:max-vertical-rotation-deg="maxVerticalRotationDeg"
|
||||||
|
:drag-sensitivity="dragSensitivity"
|
||||||
|
:enlarge-transition-ms="enlargeTransitionMs"
|
||||||
|
:segments="segments"
|
||||||
|
:drag-dampening="dragDampening"
|
||||||
|
:opened-image-width="openedImageWidth"
|
||||||
|
:opened-image-height="openedImageHeight"
|
||||||
|
:image-border-radius="imageBorderRadius"
|
||||||
|
:opened-image-border-radius="openedImageBorderRadius"
|
||||||
|
:grayscale="grayscale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSlider title="Fit" v-model="fit" :min="0.1" :max="1" :step="0.05" />
|
||||||
|
|
||||||
|
<PreviewSelect title="Fit Basis" v-model="fitBasis" :options="fitBasisOptions" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Min Radius" v-model="minRadius" :min="200" :max="800" :step="50" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Pad Factor" v-model="padFactor" :min="0.1" :max="0.5" :step="0.05" />
|
||||||
|
|
||||||
|
<PreviewColor title="Overlay Blur Color" v-model="overlayBlurColor" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Max Vertical Rotation" v-model="maxVerticalRotationDeg" :min="0" :max="15" :step="1" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Drag Sensitivity" v-model="dragSensitivity" :min="5" :max="50" :step="5" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Enlarge Transition (ms)" v-model="enlargeTransitionMs" :min="200" :max="800" :step="50" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Segments" v-model="segments" :min="20" :max="50" :step="5" />
|
||||||
|
|
||||||
|
<PreviewSlider title="Drag Dampening" v-model="dragDampening" :min="0.5" :max="5" :step="0.5" />
|
||||||
|
|
||||||
|
<PreviewText title="Opened Image Width" v-model="openedImageWidth" placeholder="e.g., 400px" />
|
||||||
|
|
||||||
|
<PreviewText title="Opened Image Height" v-model="openedImageHeight" placeholder="e.g., 400px" />
|
||||||
|
|
||||||
|
<PreviewText title="Image Border Radius" v-model="imageBorderRadius" placeholder="e.g., 30px" />
|
||||||
|
|
||||||
|
<PreviewText title="Opened Image Border Radius" v-model="openedImageBorderRadius" placeholder="e.g., 30px" />
|
||||||
|
|
||||||
|
<PreviewSwitch title="Grayscale" v-model="grayscale" />
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
|
||||||
|
<Dependencies :dependency-list="[]" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="domeGallery" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="domeGallery.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import TabbedLayout from '../../components/common/TabbedLayout.vue';
|
||||||
|
import PropTable from '../../components/common/PropTable.vue';
|
||||||
|
import Dependencies from '../../components/code/Dependencies.vue';
|
||||||
|
import CliInstallation from '../../components/code/CliInstallation.vue';
|
||||||
|
import CodeExample from '../../components/code/CodeExample.vue';
|
||||||
|
import Customize from '../../components/common/Customize.vue';
|
||||||
|
import PreviewSlider from '../../components/common/PreviewSlider.vue';
|
||||||
|
import PreviewColor from '../../components/common/PreviewColor.vue';
|
||||||
|
import PreviewSelect from '../../components/common/PreviewSelect.vue';
|
||||||
|
import PreviewText from '../../components/common/PreviewText.vue';
|
||||||
|
import PreviewSwitch from '../../components/common/PreviewSwitch.vue';
|
||||||
|
import DomeGallery from '../../content/Components/DomeGallery/DomeGallery.vue';
|
||||||
|
import { domeGallery } from '@/constants/code/Components/domeGalleryCode';
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender';
|
||||||
|
|
||||||
|
const fit = ref(0.8);
|
||||||
|
const fitBasis = ref<'auto' | 'min' | 'max' | 'width' | 'height'>('auto');
|
||||||
|
const minRadius = ref(600);
|
||||||
|
const maxRadius = ref(Infinity);
|
||||||
|
const padFactor = ref(0.25);
|
||||||
|
const overlayBlurColor = ref('#060010');
|
||||||
|
const maxVerticalRotationDeg = ref(0);
|
||||||
|
const dragSensitivity = ref(20);
|
||||||
|
const enlargeTransitionMs = ref(300);
|
||||||
|
const segments = ref(34);
|
||||||
|
const dragDampening = ref(2);
|
||||||
|
const openedImageWidth = ref('250px');
|
||||||
|
const openedImageHeight = ref('350px');
|
||||||
|
const imageBorderRadius = ref('30px');
|
||||||
|
const openedImageBorderRadius = ref('30px');
|
||||||
|
const grayscale = ref(true);
|
||||||
|
|
||||||
|
const { rerenderKey } = useForceRerender();
|
||||||
|
|
||||||
|
const fitBasisOptions = [
|
||||||
|
{ label: 'Auto', value: 'auto' },
|
||||||
|
{ label: 'Minimum', value: 'min' },
|
||||||
|
{ label: 'Maximum', value: 'max' },
|
||||||
|
{ label: 'Width', value: 'width' },
|
||||||
|
{ label: 'Height', value: 'height' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'images',
|
||||||
|
type: 'Array<string | { src: string; alt?: string }>',
|
||||||
|
default: 'DEFAULT_IMAGES',
|
||||||
|
description: 'Array of images to display. Can be URLs or objects with src and alt properties.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fit',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.5',
|
||||||
|
description: 'Size factor for dome radius relative to container dimensions.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fitBasis',
|
||||||
|
type: '"auto" | "min" | "max" | "width" | "height"',
|
||||||
|
default: '"auto"',
|
||||||
|
description: 'Determines which container dimension to use as basis for dome sizing.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'minRadius',
|
||||||
|
type: 'number',
|
||||||
|
default: '600',
|
||||||
|
description: 'Minimum dome radius in pixels.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'maxRadius',
|
||||||
|
type: 'number',
|
||||||
|
default: 'Infinity',
|
||||||
|
description: 'Maximum dome radius in pixels.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'padFactor',
|
||||||
|
type: 'number',
|
||||||
|
default: '0.25',
|
||||||
|
description: 'Padding factor for viewer area around the dome.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'overlayBlurColor',
|
||||||
|
type: 'string',
|
||||||
|
default: '"#060010"',
|
||||||
|
description: 'Color used for blur overlay and edge fading effects.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'maxVerticalRotationDeg',
|
||||||
|
type: 'number',
|
||||||
|
default: '5',
|
||||||
|
description: 'Maximum vertical rotation angle in degrees for drag interactions.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dragSensitivity',
|
||||||
|
type: 'number',
|
||||||
|
default: '20',
|
||||||
|
description: 'Sensitivity of drag interactions. Lower values require more movement.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enlargeTransitionMs',
|
||||||
|
type: 'number',
|
||||||
|
default: '300',
|
||||||
|
description: 'Duration of image enlargement animation in milliseconds.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'segments',
|
||||||
|
type: 'number',
|
||||||
|
default: '35',
|
||||||
|
description: 'Number of grid segments for image placement on the dome.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dragDampening',
|
||||||
|
type: 'number',
|
||||||
|
default: '2',
|
||||||
|
description: 'Inertia damping factor for momentum after drag release.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'openedImageWidth',
|
||||||
|
type: 'string',
|
||||||
|
default: '"250px"',
|
||||||
|
description: 'Width of enlarged image when opened.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'openedImageHeight',
|
||||||
|
type: 'string',
|
||||||
|
default: '"350px"',
|
||||||
|
description: 'Height of enlarged image when opened.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'imageBorderRadius',
|
||||||
|
type: 'string',
|
||||||
|
default: '"30px"',
|
||||||
|
description: 'Border radius for image tiles on the dome.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'openedImageBorderRadius',
|
||||||
|
type: 'string',
|
||||||
|
default: '"30px"',
|
||||||
|
description: 'Border radius for enlarged image when opened.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'grayscale',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Whether to apply grayscale filter to images.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user