You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
10 KiB
288 lines
10 KiB
import { Html } from '@react-three/drei'
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import * as THREE from 'three';
|
|
import { useFrame, useThree } from '@react-three/fiber';
|
|
import gsap from 'gsap';
|
|
import { SplitText } from 'gsap/SplitText';
|
|
|
|
gsap.registerPlugin(SplitText) ;
|
|
|
|
|
|
export const PointScale=200;
|
|
const PointSize=1;
|
|
|
|
const PointDuration=5;
|
|
const KeywordOffset=0;
|
|
const SceneDuration=0.5; // 總場景時間,用於計算延遲
|
|
|
|
const DEPTH_CONFIG = {
|
|
NEAR: 1200, // 在此距離內顯示最清晰
|
|
FAR: 1500, // 超過此距離後幾乎隱藏或變成符號
|
|
FADE_START: 800
|
|
};
|
|
|
|
|
|
|
|
export const PointContentType={
|
|
Full: 'full',
|
|
Teaser: 'teaser',
|
|
Blur: 'blur',
|
|
Symbol: 'symbol',
|
|
}
|
|
|
|
|
|
export default function Point({point, index, totalPoints, result, showContent, showKeyword, contentType, textToShow, color}) {
|
|
|
|
const [payload, setPayload]=useState();
|
|
const [showPayload, setShowPayload]=useState(false);
|
|
|
|
const meshRef=useRef();
|
|
const refText=useRef();
|
|
const { camera } = useThree();
|
|
const refAnimation=useRef({t:0});
|
|
|
|
function getRandomText(text){
|
|
|
|
if(contentType==PointContentType.Blur) return text.slice(0, textToShow) + '...';
|
|
|
|
const length=text.length;
|
|
const characters='*@#$%^&';
|
|
let result='';
|
|
for(let i=0; i<length; i++){
|
|
if(Math.random() < 0.8)
|
|
result+=characters.charAt(Math.floor(Math.random() * characters.length));
|
|
else
|
|
result+=' ';
|
|
}
|
|
return result.slice(0, textToShow/2);
|
|
}
|
|
|
|
function getContent(){
|
|
if(!payload) return '';
|
|
// console.log("Point contentType:", contentType, payload);
|
|
// if(payload.normalized_time===-1){
|
|
// return <span>{payload.text}</span>
|
|
// }
|
|
|
|
switch(contentType){
|
|
case PointContentType.Full:
|
|
return payload.content.slice(0, textToShow) + (payload.content.length > textToShow ? '...' : '');
|
|
case PointContentType.Teaser:
|
|
return `<span>${payload.teaser}</span>`;
|
|
case PointContentType.Blur:
|
|
case PointContentType.Symbol:
|
|
const start=payload.content.indexOf(payload.teaser);
|
|
if(start===-1)
|
|
return `<span>${payload.teaser}</span>`;
|
|
|
|
const before=payload.content.substring(0, start);
|
|
const after=payload.content.substring(start + payload.teaser.length);
|
|
|
|
return `<span class=${contentType}>${getRandomText(before)}</span>${payload.teaser}<span class=${contentType}>${getRandomText(after)}</span>`;
|
|
}
|
|
|
|
}
|
|
|
|
// 每幀計算距離並更新樣式,同時處理縮放動畫
|
|
useFrame((state) => {
|
|
if (!meshRef.current || !refText.current || !payload) return;
|
|
|
|
|
|
const time = state.clock.elapsedTime;
|
|
const lifeTimeOffset = (payload.number / payload.total) || 0;
|
|
|
|
// 1. 處理物體本身的脈衝縮放動畫 (替代 GSAP)
|
|
// 使用正弦波模擬 PointDuration 的循環
|
|
const pulseSpeed = Math.PI / PointDuration;
|
|
const pulse = 1;//Math.abs(Math.sin(time * pulseSpeed + lifeTimeOffset * Math.PI));
|
|
const currentMeshScale = THREE.MathUtils.lerp(0.5, PointSize, pulse);
|
|
meshRef.current.scale.set(currentMeshScale, currentMeshScale, currentMeshScale);
|
|
|
|
// 2. 計算與攝影機的距離處理深度視覺效果
|
|
const worldPosition = new THREE.Vector3();
|
|
meshRef.current.getWorldPosition(worldPosition);
|
|
const distance = camera.position.distanceTo(worldPosition);
|
|
|
|
// 根據距離計算 alpha (0 到 1)
|
|
let alpha = 1 - (distance - DEPTH_CONFIG.NEAR) / (DEPTH_CONFIG.FAR - DEPTH_CONFIG.NEAR);
|
|
alpha = THREE.MathUtils.clamp(alpha, 0.2, 1);
|
|
|
|
// 根據距離調整縮放感 (非線性縮放讓遠處文字縮小)
|
|
const depthScaleFactor = THREE.MathUtils.clamp(1.5 - (distance / 300), 0.5, 1.2);
|
|
|
|
// 更新 HTML 樣式
|
|
if (refText.current) {
|
|
|
|
|
|
// 結合深度縮放與物體本身的脈衝狀態
|
|
const finalHtmlScale = depthScaleFactor * (currentMeshScale / PointSize);
|
|
refText.current.style.transform = `translate(-50%, -50%) scale(${finalHtmlScale})`;
|
|
|
|
if(payload.keywords.length==1){
|
|
// meshRef.current.scale.set(PointSize, PointSize, PointSize);
|
|
refText.current.style.opacity = 1;
|
|
refText.current.style.transform = `translate(-50%, -50%) scale(1)`;
|
|
refText.current.style.filter = `none`;
|
|
|
|
}else{
|
|
refText.current.style.opacity *= alpha;
|
|
// 加入 CSS 濾鏡:越遠越模糊
|
|
const blurAmount = distance > DEPTH_CONFIG.FADE_START ? (distance - DEPTH_CONFIG.FADE_START) / 20 : 0;
|
|
refText.current.style.filter = `blur(${Math.min(blurAmount, 2)}px)`;
|
|
}
|
|
// 效能優化
|
|
// refText.current.style.display = alpha <= 0.01 ? 'none' : 'block';
|
|
}
|
|
});
|
|
function setTextStyle(t){
|
|
if(refText.current){
|
|
refText.current.style.opacity=t;
|
|
refText.current.style.transform=`translate(-50%, -50%) scale(${t})`;
|
|
}
|
|
}
|
|
|
|
useEffect(()=>{
|
|
// return;
|
|
|
|
if(!payload) return;
|
|
|
|
const lifeTime=payload.normalized_time;
|
|
if(lifeTime===-1) return; // keyword 不參與動畫
|
|
|
|
// console.log("Point lifeTime:", lifeTime);
|
|
|
|
// animate point size based on lifeTime
|
|
const targetSize=1;
|
|
|
|
const timeline=gsap.timeline({ repeat:-1});
|
|
timeline.fromTo(refAnimation.current,{t:0}, {
|
|
t:0,
|
|
onUpdate: ()=>{
|
|
setTextStyle(refAnimation.current.t);
|
|
}
|
|
}, lifeTime * SceneDuration);
|
|
timeline.to(refAnimation.current,{
|
|
t: 1,
|
|
duration: PointDuration,
|
|
ease: 'power1.inOut',
|
|
onComplete: ()=>{
|
|
// console.log("Show text for point:", index);
|
|
},
|
|
onUpdate: ()=>{
|
|
setTextStyle(refAnimation.current.t);
|
|
}
|
|
}); // 根據 lifeTime 設定延遲時間
|
|
|
|
|
|
|
|
timeline.to(refAnimation.current,{
|
|
t: 0,
|
|
duration: PointDuration,
|
|
onComplete: ()=>{
|
|
// console.log("Hide text for point:", index);
|
|
},
|
|
onUpdate: ()=>{
|
|
setTextStyle(refAnimation.current.t);
|
|
}
|
|
});
|
|
// timeline.to(refAnimation.current,{t:0}, {
|
|
// duration: SceneDuration - (lifeTime * SceneDuration + PointDuration * 2),
|
|
// }); // 剩餘時間保持隱藏
|
|
|
|
timeline.play();
|
|
|
|
return ()=>{
|
|
timeline.kill();
|
|
};
|
|
|
|
},[payload]);
|
|
|
|
useEffect(()=>{
|
|
|
|
if(!showContent) return;
|
|
if(!refText.current) return;
|
|
|
|
// const tosplit=refText.current.getElementsByClassName('splittext')[0];
|
|
// if(!tosplit) return;
|
|
|
|
// let split=SplitText.create(tosplit, {type: "words, chars"});
|
|
// console.log("SplitText chars:", split.chars.length);
|
|
|
|
// gsap.fromTo(split.chars, {
|
|
// // opacity: 0,
|
|
// y: 50,
|
|
// stagger: 0.05
|
|
// }, {
|
|
// // opacity: 1,
|
|
// y: -50,
|
|
// duration: 0.5,
|
|
// ease: 'power2.inOut',
|
|
// repeat:-1,
|
|
// });
|
|
|
|
// return ()=>{
|
|
// // split.revert();
|
|
// gsap.killTweensOf(split.chars);
|
|
// };
|
|
},[showContent])
|
|
|
|
useEffect(()=>{
|
|
|
|
if(!result) return;
|
|
|
|
console.log("Point result:", result, color);
|
|
if(result?.type==='keyword'){
|
|
const title=JSON.parse(result.payload.text??'{}').title;
|
|
const payloadKeyword={
|
|
...JSON.parse(result.payload.text??'{}'),
|
|
keywords: [title],
|
|
number: 0,
|
|
total: 1,
|
|
content: title || '',
|
|
teaser: title ||'',
|
|
normalized_time: -1,
|
|
type: result.type || 'keyword',
|
|
};
|
|
setPayload(payloadKeyword);
|
|
return;
|
|
}
|
|
setPayload({
|
|
...JSON.parse(result.payload.metadata??'{}'),
|
|
keywords: result.payload.keywords || [],
|
|
number: result.payload.number || 0,
|
|
total: result.payload.total || 1,
|
|
content: JSON.parse(result.payload.metadata??'{}').text || '',
|
|
teaser: result.payload.teaser || '',
|
|
normalized_time: result.payload.normalized_time || 0,
|
|
type: result.type || 'data',
|
|
});
|
|
|
|
},[result]);
|
|
|
|
|
|
return (
|
|
<mesh ref={meshRef} position={[point[0]*PointScale, point[1]*PointScale, point[2]*PointScale]}>
|
|
<sphereGeometry args={[PointSize, 32, 32]} />
|
|
<meshStandardMaterial
|
|
color={new THREE.Color().set(color)}
|
|
emissive={new THREE.Color().set(color)}
|
|
emissiveIntensity={0.5}
|
|
/>
|
|
<Html>
|
|
<div ref={refText} className=' p-2 select-none text-center opacity-0 flex flex-col items-center justify-center'>
|
|
{showKeyword && payload?.keywords.length>1 && <div className={`flex flex-row justify-center flex-wrap text-[1rem] ${showKeyword ? 'block' : 'hidden'}`}
|
|
style={{color: color || 'white'}}>
|
|
{payload?.keywords.map((el, index)=><span key={index} className='px-2 bg-gray-800 rounded-full' style={{transform: `translate(${Math.random() * KeywordOffset}px,${Math.random() * KeywordOffset}px)`}}>{el}</span>)}
|
|
</div>}
|
|
{/* {color!='white' && !showKeyword && payload?.keywords.length>1 && <div className='w-[1rem] aspect-square rounded-full' style={{backgroundColor: color}}></div>} */}
|
|
{<div
|
|
className={`text-[2rem] rounded p-2 text-center max-w-[50vw] min-w-[20vw] splittext ${showContent ? 'block' : 'hidden'} ${payload?.type??''}`}
|
|
// style={{width: `${Math.random()*(payload?.content.length/5 || 10) + 20}vw`}}
|
|
style={{color: color}}
|
|
dangerouslySetInnerHTML={{__html: getContent()}}
|
|
></div>}
|
|
</div>
|
|
</Html>
|
|
</mesh>
|
|
)
|
|
} |