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

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>
)
}