|
|
|
|
@ -1,32 +1,129 @@ |
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const PointScale=200; |
|
|
|
|
const PointSize=2; |
|
|
|
|
const PointSize=1; |
|
|
|
|
|
|
|
|
|
const PointDuration=3; |
|
|
|
|
const KeywordOffset=0; |
|
|
|
|
|
|
|
|
|
const TextToShow=12; |
|
|
|
|
const DEPTH_CONFIG = { |
|
|
|
|
NEAR: 600, // 在此距離內顯示最清晰 |
|
|
|
|
FAR: 1000, // 超過此距離後幾乎隱藏或變成符號 |
|
|
|
|
FADE_START: 400 |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
export default function Point({point, index, totalPoints, result, showContent, showKeyword}) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const PointContentType={ |
|
|
|
|
Full: 'full', |
|
|
|
|
Teaser: 'teaser', |
|
|
|
|
Blur: 'blur', |
|
|
|
|
Symbol: 'symbol', |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default function Point({point, index, totalPoints, result, showContent, showKeyword, contentType, textToShow}) { |
|
|
|
|
|
|
|
|
|
const [payload, setPayload]=useState(); |
|
|
|
|
const [showPayload, setShowPayload]=useState(false); |
|
|
|
|
|
|
|
|
|
const meshRef=useRef(); |
|
|
|
|
const refText=useRef(); |
|
|
|
|
const { camera } = useThree(); |
|
|
|
|
|
|
|
|
|
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 ''; |
|
|
|
|
|
|
|
|
|
switch(contentType){ |
|
|
|
|
case PointContentType.Full: |
|
|
|
|
return payload.content.slice(0, textToShow) + (payload.content.length > textToShow ? '...' : ''); |
|
|
|
|
case PointContentType.Teaser: |
|
|
|
|
return payload.teaser; |
|
|
|
|
case PointContentType.Blur: |
|
|
|
|
case PointContentType.Symbol: |
|
|
|
|
const start=payload.content.indexOf(payload.teaser); |
|
|
|
|
if(start===-1) return payload.teaser; |
|
|
|
|
|
|
|
|
|
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, 1); |
|
|
|
|
|
|
|
|
|
// 根據距離調整縮放感 (非線性縮放讓遠處文字縮小) |
|
|
|
|
const depthScaleFactor = THREE.MathUtils.clamp(1.5 - (distance / 300), 0.5, 1.2); |
|
|
|
|
|
|
|
|
|
// 更新 HTML 樣式 |
|
|
|
|
if (refText.current) { |
|
|
|
|
refText.current.style.opacity = alpha; |
|
|
|
|
// 結合深度縮放與物體本身的脈衝狀態 |
|
|
|
|
const finalHtmlScale = depthScaleFactor * (currentMeshScale / PointSize); |
|
|
|
|
refText.current.style.transform = `translate(-50%, -50%) scale(${finalHtmlScale})`; |
|
|
|
|
|
|
|
|
|
// 加入 CSS 濾鏡:越遠越模糊 |
|
|
|
|
const blurAmount = distance > DEPTH_CONFIG.FADE_START ? (distance - DEPTH_CONFIG.FADE_START) / 20 : 0; |
|
|
|
|
refText.current.style.filter = `blur(${Math.min(blurAmount, 4)}px)`; |
|
|
|
|
|
|
|
|
|
// 效能優化 |
|
|
|
|
refText.current.style.display = alpha <= 0.01 ? 'none' : 'block'; |
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
useEffect(()=>{ |
|
|
|
|
return; |
|
|
|
|
|
|
|
|
|
if(!payload) return; |
|
|
|
|
const lifeTime=payload.number/payload.total; |
|
|
|
|
console.log("Point lifeTime:", lifeTime); |
|
|
|
|
|
|
|
|
|
// animate point size based on lifeTime |
|
|
|
|
const targetSize=1.0; |
|
|
|
|
const targetSize=1; |
|
|
|
|
const initialSize=targetSize; |
|
|
|
|
|
|
|
|
|
const timeline=gsap.timeline({ repeat:-1}); |
|
|
|
|
@ -79,6 +176,7 @@ export default function Point({point, index, totalPoints, result, showContent, s |
|
|
|
|
number: result.payload.number || '0', |
|
|
|
|
total: result.payload.total || '1', |
|
|
|
|
content: JSON.parse(result.payload.metadata).text || '', |
|
|
|
|
teaser: result.payload.teaser || '', |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
},[point]); |
|
|
|
|
@ -97,9 +195,11 @@ export default function Point({point, index, totalPoints, result, showContent, s |
|
|
|
|
{showKeyword && <div className='flex flex-row justify-center flex-wrap text-[1rem]'> |
|
|
|
|
{payload?.keywords.map((el, index)=><span key={index} className='px-2' style={{transform: `translate(${Math.random() * KeywordOffset}px,${Math.random() * KeywordOffset}px)`}}>{el}</span>)} |
|
|
|
|
</div>} |
|
|
|
|
{showContent && <pre className='text-lg w-[25vw] whitespace-pre-wrap text-white rounded p-2'>{(()=>{ |
|
|
|
|
return `${payload?.content.slice(0, TextToShow)}${payload?.content.length>TextToShow ? '...' : ''}`; |
|
|
|
|
})()}</pre>} |
|
|
|
|
{showContent && <div |
|
|
|
|
className='text-[2rem] whitespace-pre-wrap text-white rounded p-2 text-left' |
|
|
|
|
style={{width: `${Math.random()*(payload?.content.length/5 || 10) + 20}vw`}} |
|
|
|
|
dangerouslySetInnerHTML={{__html: getContent()}} |
|
|
|
|
></div>} |
|
|
|
|
</div> |
|
|
|
|
</Html> |
|
|
|
|
</mesh> |
|
|
|
|
|