/* EntecPower corporate site — Home page content */
/* EntecPower corporate site — Home page content */
/* EntecPower corporate site — Home page content */
const getImageCoverCoords = (xPercent, yPercent, containerW, containerH, imgW = 1024, imgH = 1024) => {
const imgRatio = imgW / imgH;
const containerRatio = containerW / containerH;
let scaledW, scaledH, offsetX, offsetY;
if (containerRatio > imgRatio) {
scaledW = containerW;
scaledH = containerW / imgRatio;
offsetX = 0;
offsetY = (containerH - scaledH) / 2;
} else {
scaledW = containerH * imgRatio;
scaledH = containerH;
offsetX = (containerW - scaledW) / 2;
offsetY = 0;
}
return {
x: offsetX + (xPercent / 100) * scaledW,
y: offsetY + (yPercent / 100) * scaledH
};
};
const SPOT_LOCK_PX = 90;
const SCENES = [
{
id: 'substation',
title: { ko: '변전소 주요 설비 안전 진단', en: 'Substation Asset Diagnostics' },
sub: { ko: '초고압 애자련 누전 분석 및 대형 변압기 부싱 실시간 열화 진단', en: 'UHV insulator leak analysis & transformer bushing thermal degradation' },
bg: 'company/assets/imagery/substation_day.png',
stats: [
{ v: '98.7', u: '%', l: { ko: '진단 정밀도', en: 'PD Detection' } },
{ v: '50', u: 'm', l: { ko: '최대 검출 거리', en: 'Max Standoff' } }
],
hotspots: [
{
x: 28, y: 38,
type: 'thermal',
label: { ko: '변압기 R상 부싱 접속부 과열 (CRITICAL)', en: 'Transformer R-Phase Bushing Overheat (CRITICAL)' },
telemetry: 'TR_BUSH_R',
temp: 84.6, db: 11, dist: 12.4,
desc: { ko: '부싱 전극 결선 풀림에 따른 접촉저항 및 전류 집중 과열.', en: 'Overheating due to loose connector contact resistance & current load concentration.' }
},
{
x: 72, y: 28,
type: 'thermal',
label: { ko: '변압기 S상 부싱 이상 발열 (WARNING)', en: 'Transformer S-Phase Bushing Overheat (WARNING)' },
telemetry: 'TR_BUSH_S',
temp: 59.3, db: 7, dist: 12.8,
desc: { ko: '부싱 절연 노화에 따른 초기 미세 유도 열화.', en: 'Early stage induction heating due to bushing insulation degradation.' }
},
{
x: 18, y: 72,
type: 'thermal',
label: { ko: '변압기 냉각 방열핀 상단 폐쇄 과열 (WARNING)', en: 'Transformer Radiator Fin Overheat (WARNING)' },
telemetry: 'TR_RAD_FIN_04',
temp: 68.5, db: 8, dist: 18.2,
desc: { ko: '방열판 내부 냉각 오일 순환 마찰 및 배관 오염 누적에 따른 방열 방해.', en: 'Poor heat dissipation due to cooling oil flow blockage in radiator fin.' }
},
{
x: 42, y: 55,
type: 'prpd',
label: { ko: '154kV 현수애자련 코로나 방전 (WARNING)', en: '154kV Suspension Insulator PD (WARNING)' },
telemetry: 'INS_CHAIN_08',
temp: 24.5, db: 46, dist: 15.6,
desc: { ko: '애자 표면 염해 노출에 따른 누설 전류 아크 코로나 방전.', en: 'Surface leakage arc corona discharge due to salt & moisture build-up.' }
},
{
x: 62, y: 68,
type: 'prpd',
label: { ko: '피뢰기(SA) 열화에 의한 내부 방전', en: 'Surge Arrester Internal PD' },
telemetry: 'SURGE_ARR_B3',
temp: 25.1, db: 41, dist: 28.3,
desc: { ko: '피뢰기 내부 소자 노화로 인한 연속 부분방전 신호 및 누전 감지.', en: 'Continuous partial discharge inside SA housing due to element aging.' }
},
{
x: 48, y: 44,
type: 'mixed',
label: { ko: '변압기 2차측 인입선 접촉 아크 및 과열 (DANGER)', en: 'Transformer Secondary Busway Arcing & Overheat (DANGER)' },
telemetry: 'TR_SEC_BUSBAR',
temp: 42.8, db: 48, dist: 19.5,
desc: { ko: '초기 부분 방전이 지속되며 아크에 의한 금속 접촉부 온도 상승 단계 (융복합 진단 핵심 지점).', en: 'Early stage partial discharge transforming to heat due to constant arcing.' }
}
]
},
{
id: 'gas_plant',
title: { ko: '석유화학 플랜트 배관 연결부', en: 'Petrochemical Pipeline Joints' },
sub: { ko: '송유관 고압 밸브 가스 누출 및 플랜지 용접부 균열 동시 감시', en: 'High-pressure valve leaks & flange weld crack inspection' },
bg: 'company/assets/imagery/gas_plant_day.png',
stats: [
{ v: '0.1', u: 'l/min', l: { ko: '최소 가스 누출', en: 'Micro Leak Limit' } },
{ v: '45', u: 'm', l: { ko: '안전 측정 이격', en: 'Safe Distance' } }
],
hotspots: [
{
x: 22, y: 48,
type: 'thermal',
label: { ko: '고압 증기 밸브 패킹 마찰 고열 (CRITICAL)', en: 'High-Pressure Steam Valve Overheat (CRITICAL)' },
telemetry: 'STEAM_VALVE_02',
temp: 92.1, db: 8, dist: 16.5,
desc: { ko: '고압 스팀 유체 개폐 마찰 및 차단막 밀봉 성능 노후화로 인한 열화.', en: 'Friction overheat at steam block valve due to sealant degradation.' }
},
{
x: 68, y: 30,
type: 'thermal',
label: { ko: '이송 유체 급유 배관 마찰 온도 상승', en: 'Fuel pipe friction heat' },
telemetry: 'FEED_PIPE_V12',
temp: 72.4, db: 11, dist: 22.8,
desc: { ko: '이송 압력 급상승으로 인한 배관 곡관부 기계적 마찰 온도 누적.', en: 'Localized pipe friction heating from sudden product flow turbulence.' }
},
{
x: 15, y: 25,
type: 'thermal',
label: { ko: '오일 저장 탱크 히팅 히터 오동작 고열', en: 'Oil Tank Heater Anomaly' },
telemetry: 'OIL_TANK_H3',
temp: 80.4, db: 7, dist: 34.0,
desc: { ko: '탱크 하단 전기 히터 접촉 불량 및 온도 센서 고장으로 인한 지속 가열.', en: 'Uncontrolled continuous heating due to level switch failure.' }
},
{
x: 38, y: 66,
type: 'prpd',
label: { ko: '가스 배관 연결용 플랜지 미세 가스 누출 (WARNING)', en: 'Gas Pipe Flange Micro-Leak (WARNING)' },
telemetry: 'FLANGE_GAS_L10',
temp: 18.2, db: 52, dist: 11.2,
desc: { ko: '가스 고압 분출에 따른 고주파 초음파 충격음 발생. PRPD 신호 뚜렷함.', en: 'High-frequency ultrasonic impact acoustic signal from high-pressure gas escape.' }
},
{
x: 58, y: 80,
type: 'prpd',
label: { ko: '가스 저장 기화기 안전 릴리프 밸브 바이패스', en: 'Vaporizer Relief Valve Bypass Leak' },
telemetry: 'SAFETY_VALVE_B4',
temp: 21.4, db: 44, dist: 29.4,
desc: { ko: '안전밸브 노화에 따른 압력 미세 방출 가스 누출 신호 포착.', en: 'Ultrasonic leak detection showing vapor leakage from relief valve seat.' }
},
{
x: 42, y: 40,
type: 'mixed',
label: { ko: '고압 배관 엘보 용접 크랙 가스 분출 및 가열 (DANGER)', en: 'High-Pressure Pipe Elbow Crack (DANGER)' },
telemetry: 'PIPE_ELBOW_CRACK',
temp: 38.5, db: 49, dist: 22.3,
desc: { ko: '파이프 피로 파괴 미세 균열로 유체가 분출되며 주변 마찰 마모 온도와 초음파 융복합 검출.', en: 'Fluid friction and leak acoustic signature simultaneously detected at elbow weld.' }
}
]
},
{
id: 'datacenter',
title: { ko: '실내 대형 데이터센터 전원 및 냉각 설비', en: 'Data Center Power & Cooling Cabinets' },
sub: { ko: '서버 랙 PDU 단자 접촉 이상 과열 및 UPS 배터리실 아크 방전 상시 감시', en: 'PDU contact overheating & UPS room arc discharge monitoring' },
bg: 'company/assets/imagery/datacenter_day.png',
stats: [
{ v: '24/7', u: '상시', l: { ko: '중단 없는 감시', en: 'Non-stop Scan' } },
{ v: '15', u: 'm', l: { ko: '실내 밀착 진단', en: 'Indoor Close-Up' } }
],
hotspots: [
{
x: 20, y: 62,
type: 'thermal',
label: { ko: '서버 랙 B열 전원분배 장치(PDU) 접속부 과열 (CRITICAL)', en: 'Server Rack Row B PDU Contact Overheat (CRITICAL)' },
telemetry: 'DC_PDU_ROW_B',
temp: 71.5, db: 10, dist: 9.8,
desc: { ko: '과부하 전류 인입 및 PDU 단자 체결 저하에 따른 이상 고열.', en: 'High-current draw on loose PDU copper connection terminals.' }
},
{
x: 55, y: 78,
type: 'thermal',
label: { ko: '서버 랙 C열 메인 인입선 접촉 과열', en: 'Server Rack Row C Main Contact Heat' },
telemetry: 'DC_PDU_ROW_C',
temp: 55.4, db: 5, dist: 10.2,
desc: { ko: '배전 보드 커넥터 산화막 형성에 따른 저항 증가 발열.', en: 'Contact degradation and resistance heating on row distribution connector.' }
},
{
x: 72, y: 22,
type: 'thermal',
label: { ko: '메인 항온항습 냉각 루버 팬 유닛 과열 (WARNING)', en: 'Main HVAC Cooling Fan Unit Overheat (WARNING)' },
telemetry: 'HVAC_FAN_03',
temp: 58.2, db: 12, dist: 14.2,
desc: { ko: '팬 베어링 윤활 고갈로 인한 회전 마찰 부하 이상 발열 현상.', en: 'HVAC fan motor friction heat due to bearing grease drying out.' }
},
{
x: 30, y: 32,
type: 'prpd',
label: { ko: 'UPS 백업 배터리 모듈실 내부 아크 방전 (WARNING)', en: 'UPS Backup Battery Room Internal Arc (WARNING)' },
telemetry: 'UPS_BATTERY_PD',
temp: 26.8, db: 42, dist: 18.5,
desc: { ko: 'DC 버스바 절연 노후화로 인한 절연 트래킹 및 미세 아크 방전 발생.', en: 'DC busbar insulation degradation causing arc tracking & discharge.' }
},
{
x: 58, y: 50,
type: 'prpd',
label: { ko: '천장 케이블 트레이 광역 전원선 절연 파괴', en: 'Overhead Cable Tray Cable PD' },
telemetry: 'CABLE_TRAY_SEC3',
temp: 24.2, db: 38, dist: 11.4,
desc: { ko: '천장 전력 배선 트래킹 방전 신호 포착. 눈으로 보이지 않는 케이블 내부 결함.', en: 'Hidden insulation cracking inside overhead power supply cables.' }
},
{
x: 38, y: 46,
type: 'mixed',
label: { ko: '메인 전력 버스덕트 조인트 누설 및 발열 (DANGER)', en: 'Main Bus Duct Joint Leakage & Overheat (DANGER)' },
telemetry: 'MAIN_BUS_DUCT',
temp: 40.2, db: 47, dist: 16.2,
desc: { ko: '전력 배전 버스덕트 이음새의 내부 절연 틈새 미세 방전(초음파)이 열(온도 상승)로 진행 중인 단계.', en: 'Insulation tracking transitioning into contact heat at the bus duct coupling joint.' }
}
]
},
{
id: 'switchgear',
title: { ko: '특고압 폐쇄형 배전반 내부', en: 'HV Closed Switchgear Panel' },
sub: { ko: '차단 장치 내부 접속 단자 볼트 풀림 및 부분 방전 상시 감시', en: 'Cradle contact loose bolts & vacuum circuit breaker partial discharge' },
bg: 'company/assets/imagery/switchgear_day.png',
stats: [
{ v: '19', u: '개국', l: { ko: '글로벌 레퍼런스', en: 'Global Reference' } },
{ v: '98.7', u: '%', l: { ko: '진단 정밀도', en: 'Diagnostic Accuracy' } }
],
hotspots: [
{
x: 25, y: 35,
type: 'thermal',
label: { ko: 'R상 동부스바 접속부 접촉 결함 (CRITICAL)', en: 'R-Phase Busbar Joint Thermal Defect (CRITICAL)' },
telemetry: 'BUSBAR_JOINT_R',
temp: 110.5, db: 9, dist: 5.4,
desc: { ko: '볼트 조임 풀림으로 접촉 저항 급상승. 화재 위험의 특고압 접속부.', en: 'Severe contact resistance from a loose bolt leading to high thermal buildup.' }
},
{
x: 65, y: 25,
type: 'thermal',
label: { ko: 'S상 부스바 볼트 체결 이상 발열 (WARNING)', en: 'S-Phase Busbar Joint Overheat (WARNING)' },
telemetry: 'BUSBAR_JOINT_S',
temp: 75.8, db: 6, dist: 5.8,
desc: { ko: '부스바 겹침부 접촉면 산화로 인한 경미한 온도 상승.', en: 'Mild overheat from surface oxidation on busbar overlapping joints.' }
},
{
x: 18, y: 68,
type: 'thermal',
label: { ko: '계기용 변류기(CT) primary 단자 접촉 과열', en: 'Current Transformer Primary Contact Heat' },
telemetry: 'CT_TERM_A2',
temp: 85.2, db: 11, dist: 6.8,
desc: { ko: '단자 체결 불량 및 장시간 하중 노후화로 인한 이상 발열 발생.', en: 'Terminal load heating at Current Transformer connection bushings.' }
},
{
x: 44, y: 55,
type: 'prpd',
label: { ko: '진공차단기 크래들 절연 방전 (WARNING)', en: 'VCB Cradle Insulation Discharge (WARNING)' },
telemetry: 'VCB_CRADLE_B',
temp: 26.8, db: 45, dist: 8.9,
desc: { ko: '크래들 지지 절연 배리어 부분 방전. 초음파 고유 위상 신호 확인.', en: 'Partial arc discharge detected at the circuit breaker socket insulation barrier.' }
},
{
x: 60, y: 72,
type: 'prpd',
label: { ko: '계기용 변압기(GPT) 1차측 부분방전', en: 'GPT Primary Partial Discharge' },
telemetry: 'CT_TERM_A3',
temp: 24.8, db: 37, dist: 7.8,
desc: { ko: '변압기 외함 부싱 절연 파괴에 의한 소규모 방전음 감지.', en: 'Small discharge sound detected on Transformer casing bushing.' }
},
{
x: 36, y: 46,
type: 'mixed',
label: { ko: 'VCB 접점 얼라인먼트 어긋남 아크 및 발열 (DANGER)', en: 'VCB Contact Alignment Arc & Overheat (DANGER)' },
telemetry: 'VCB_ALIGN_FAULT',
temp: 44.2, db: 48, dist: 6.2,
desc: { ko: '접촉 단자 중심 정렬 결함으로 상간 절연 틈새 전기 스파크(초음파)와 접촉부 열화가 함께 진행 중인 융복합 핵심 부분.', en: 'Circuit breaker main finger contact misalignment causing electrical arcing and heat.' }
}
]
}
];
function HomeHero() {
const { lang } = useSite();
const [activeScene, setActiveScene] = React.useState(0);
const [mode, setMode] = React.useState('fusion'); // 'fusion' | 'thermal' | 'ultrasound'
const [hoveredHotspot, setHoveredHotspot] = React.useState(null);
const [isAutoPlaying, setIsAutoPlaying] = React.useState(true);
const [logs, setLogs] = React.useState([]);
const [containerSize, setContainerSize] = React.useState({ width: 0, height: 0 });
const [bgImage, setBgImage] = React.useState(null);
const canvasRef = React.useRef(null);
const containerRef = React.useRef(null);
const miniPrpdRef = React.useRef(null);
const miniTempRef = React.useRef(null);
const animationFrameId = React.useRef(null);
const mouseRef = React.useRef({ x: 0, y: 0, active: false });
const crosshairRef = React.useRef({ x: 0, y: 0 });
const tempHistoryRef = React.useRef(Array(20).fill(24.0));
const sceneTimerRef = React.useRef(null);
const prpdOpacityRef = React.useRef(0);
const activePrpdHotspotRef = React.useRef(null);
// Web Audio API refs for ultrasound crackle sound simulation
const audioCtxRef = React.useRef(null);
const noiseSourceRef = React.useRef(null);
const sparkGainRef = React.useRef(null);
const hissGainRef = React.useRef(null);
const masterGainRef = React.useRef(null);
const audioTimeoutRef = React.useRef(null);
const glitchRef = React.useRef({ active: false, frames: 0, totalFrames: 0 });
const scene = SCENES[activeScene];
const activeHotspots = scene.hotspots;
// Filter hotspots based on current mode:
// - 'thermal' shows only Red (thermal) hotspots
// - 'ultrasound' shows only Cyan (prpd) hotspots
// - 'fusion' shows all (Red, Cyan, and Orange) hotspots
const visibleHotspots = activeHotspots.filter(hs => {
if (mode === 'thermal') return hs.type === 'thermal';
if (mode === 'ultrasound') return hs.type === 'prpd';
return true; // fusion
});
// Initialize Web Audio API components for partial discharge sound synthesis
const initAudio = React.useCallback(() => {
if (audioCtxRef.current) return;
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
const audioCtx = new AudioContextClass();
audioCtxRef.current = audioCtx;
// 1. Create a looped white noise buffer (2 seconds long)
const bufferSize = audioCtx.sampleRate * 2;
const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noiseSource = audioCtx.createBufferSource();
noiseSource.buffer = noiseBuffer;
noiseSource.loop = true;
noiseSourceRef.current = noiseSource;
// 2. Bandpass Filter (between 1.5kHz and 3.5kHz for fuller, more audible crackle)
const filter = audioCtx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 2400; // Center frequency for crackle hiss
filter.Q.value = 1.0; // Lower Q for a fuller, warmer sound
// 3. Hiss Gain Node (for continuous background discharge hiss)
const hissGain = audioCtx.createGain();
hissGain.gain.setValueAtTime(0, audioCtx.currentTime);
hissGainRef.current = hissGain;
// 4. Spark Gain Node (for rapid crackles / electrical arcing impulses)
const sparkGain = audioCtx.createGain();
sparkGain.gain.setValueAtTime(0, audioCtx.currentTime);
sparkGainRef.current = sparkGain;
// 5. Master Gain Node (safely capped to avoid loud blasts)
const masterGain = audioCtx.createGain();
masterGain.gain.setValueAtTime(0, audioCtx.currentTime);
masterGainRef.current = masterGain;
// Connections: NoiseSource -> Filter -> Hiss/Spark Gains -> MasterGain -> Destination
noiseSource.connect(filter);
filter.connect(hissGain);
filter.connect(sparkGain);
hissGain.connect(masterGain);
sparkGain.connect(masterGain);
masterGain.connect(audioCtx.destination);
// Start playing noise buffer
noiseSource.start(0);
// Spark scheduler loop for crackle sound simulation
const runCrackle = () => {
if (!audioCtxRef.current) return;
const opacity = prpdOpacityRef.current;
const now = audioCtx.currentTime;
if (audioCtx.state === 'suspended') {
audioTimeoutRef.current = setTimeout(runCrackle, 100);
return;
}
if (opacity > 0.01) {
// Density of sparks increases as opacity approaches 1 (hover focus)
const nextDelay = 15 + Math.random() * (120 - 100 * opacity);
// Random spark impulse volume scaled by opacity (louder than before)
const sparkVolume = (0.35 + Math.random() * 0.5) * opacity * 0.8;
const duration = 0.004 + Math.random() * 0.018; // 4ms to 22ms decay duration
sparkGain.gain.cancelScheduledValues(now);
sparkGain.gain.setValueAtTime(0, now);
sparkGain.gain.linearRampToValueAtTime(sparkVolume, now + 0.001);
sparkGain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
// Soft background ionization hiss scaled by proximity (louder than before)
hissGain.gain.setValueAtTime(opacity * 0.22, now);
audioTimeoutRef.current = setTimeout(runCrackle, nextDelay);
} else {
// No active PRPD node hovered, keep output quiet
hissGain.gain.setValueAtTime(0, now);
sparkGain.gain.setValueAtTime(0, now);
audioTimeoutRef.current = setTimeout(runCrackle, 80);
}
};
runCrackle();
} catch (err) {
console.warn("Failed to initialize Web Audio API:", err);
}
}, []);
// Cleanup Web Audio resources on unmount
React.useEffect(() => {
return () => {
if (audioTimeoutRef.current) {
clearTimeout(audioTimeoutRef.current);
}
if (noiseSourceRef.current) {
try {
noiseSourceRef.current.stop();
} catch (e) {}
}
if (audioCtxRef.current) {
audioCtxRef.current.close().catch(() => {});
audioCtxRef.current = null;
}
};
}, []);
// Ensure Web Audio API is unlocked on first user interaction (click/tap)
const unlockAudio = React.useCallback(() => {
if (!audioCtxRef.current) {
initAudio();
} else if (audioCtxRef.current.state === 'suspended') {
audioCtxRef.current.resume().catch(() => {});
}
}, [initAudio]);
React.useEffect(() => {
window.addEventListener('click', unlockAudio, { passive: true });
window.addEventListener('touchstart', unlockAudio, { passive: true });
return () => {
window.removeEventListener('click', unlockAudio);
window.removeEventListener('touchstart', unlockAudio);
};
}, [unlockAudio]);
// Track container dimensions dynamically with ResizeObserver
React.useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
const { width, height } = entry.contentRect;
setContainerSize({ width, height });
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// System Logs generator helper
const addLog = React.useCallback((text, type = 'info') => {
const time = new Date().toLocaleTimeString('ko-KR', { hour12: false });
setLogs(prev => {
const next = [...prev, { time, text, type }];
if (next.length > 4) next.shift();
return next;
});
}, []);
// Load background image for canvas drawing
React.useEffect(() => {
setBgImage(null); // Clear previous image
const img = new Image();
img.src = scene.bg;
img.onload = () => {
setBgImage(img);
};
}, [scene.bg]);
// Resize canvas when containerSize changes
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !containerSize.width || !containerSize.height) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = containerSize.width * dpr;
canvas.height = containerSize.height * dpr;
ctx.scale(dpr, dpr);
}, [containerSize]);
// Initialize and handle auto-rotation
React.useEffect(() => {
addLog(`[SYSTEM] Initialized Fusion Diagnosis Engine.`, 'success');
addLog(`[SECTOR] Switched to ${SCENES[activeScene].id.toUpperCase()}.`, 'info');
setHoveredHotspot(null);
}, [activeScene, addLog]);
React.useEffect(() => {
if (isAutoPlaying) {
sceneTimerRef.current = setInterval(() => {
setActiveScene(prev => (prev + 1) % SCENES.length);
}, 12000);
}
return () => {
if (sceneTimerRef.current) clearInterval(sceneTimerRef.current);
};
}, [isAutoPlaying]);
// Mode changes logger
React.useEffect(() => {
addLog(`[CAMERA] Switched mode to: ${mode.toUpperCase()}`, 'warn');
}, [mode, addLog]);
// Canvas interaction animation loop
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
let frame = 0;
const render = () => {
frame++;
const rect = containerSize;
if (rect.width === 0 || rect.height === 0) {
animationFrameId.current = requestAnimationFrame(render);
return;
}
ctx.clearRect(0, 0, rect.width, rect.height);
// Find locked hotspot (if mouse is active and near)
let activeHotspot = null;
let minDistance = SPOT_LOCK_PX;
if (mouseRef.current.active && visibleHotspots.length > 0) {
visibleHotspots.forEach(hs => {
const coords = getImageCoverCoords(hs.x, hs.y, rect.width, rect.height);
const dist = Math.hypot(mouseRef.current.x - coords.x, mouseRef.current.y - coords.y);
if (dist < minDistance) {
minDistance = dist;
activeHotspot = hs;
}
});
}
let targetX = mouseRef.current.x;
let targetY = mouseRef.current.y;
if (activeHotspot) {
const coords = getImageCoverCoords(activeHotspot.x, activeHotspot.y, rect.width, rect.height);
targetX = coords.x;
targetY = coords.y;
}
crosshairRef.current.x += (targetX - crosshairRef.current.x) * 0.25;
crosshairRef.current.y += (targetY - crosshairRef.current.y) * 0.25;
// Update state if lock changes
if (activeHotspot !== hoveredHotspot) {
setHoveredHotspot(activeHotspot);
if (activeHotspot) {
addLog(`[LOCK] ${activeHotspot.telemetry} acquired. dist: ${activeHotspot.dist}m.`, 'success');
// Trigger glitch visual on entering hotspot
glitchRef.current = {
active: true,
frames: 0,
totalFrames: (activeHotspot.type === 'prpd' || activeHotspot.type === 'mixed') ? 14 : 8,
};
} else {
addLog(`[TARGET] Scan mode active. Scanning...`, 'info');
}
}
// Update telemetry history refs
if (frame % 15 === 0) {
const curTemp = activeHotspot ? activeHotspot.temp : (24.0 + Math.random() * 0.8);
tempHistoryRef.current.push(curTemp);
if (tempHistoryRef.current.length > 20) tempHistoryRef.current.shift();
}
// Render diagnostic grid lines if in ultrasound mode
if (mode === 'ultrasound') {
ctx.strokeStyle = 'rgba(34, 211, 238, 0.04)';
ctx.lineWidth = 1;
// Draw grid
for (let i = 0; i < rect.width; i += 50) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, rect.height); ctx.stroke();
}
for (let j = 0; j < rect.height; j += 50) {
ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(rect.width, j); ctx.stroke();
}
}
// Draw inactive hotspots indicators and telemetry tags
visibleHotspots.forEach((hs) => {
const coords = getImageCoverCoords(hs.x, hs.y, rect.width, rect.height);
const hX = coords.x;
const hY = coords.y;
const isHot = activeHotspot === hs;
// 1. Draw inactive ambient glow for thermal/mixed hotspots (when NOT hovered)
if ((hs.type === 'thermal' || hs.type === 'mixed') && mode !== 'ultrasound') {
if (!isHot) {
const radius = 35 + Math.sin(frame * 0.08) * 4;
const intensity = 0.12;
ctx.save();
ctx.globalCompositeOperation = 'screen';
const grad = ctx.createRadialGradient(hX, hY, 2, hX, hY, radius);
grad.addColorStop(0, `rgba(239, 68, 68, ${intensity})`);
grad.addColorStop(0.4, `rgba(247, 148, 29, ${intensity * 0.5})`);
grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(hX, hY, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// 2. Draw ultrasound sonar pulsing waves for prpd/mixed hotspots (shown in Fusion / Ultrasound modes)
if ((hs.type === 'prpd' || hs.type === 'mixed') && mode !== 'thermal') {
const waveCount = 2;
for (let w = 0; w < waveCount; w++) {
const rad = ((frame * 1.0 + w * 40) % 80);
const alpha = (1 - rad / 80) * (isHot ? 0.6 : 0.08);
ctx.strokeStyle = hs.type === 'mixed' ? `rgba(249, 115, 22, ${alpha})` : `rgba(34, 211, 238, ${alpha})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(hX, hY, rad, 0, Math.PI * 2);
ctx.stroke();
}
}
});
// 3. DRAW THERMAL LENS OVERLAY ON HOVER (For thermal and mixed hotspots)
if (activeHotspot && (activeHotspot.type === 'thermal' || activeHotspot.type === 'mixed') && bgImage) {
const coords = getImageCoverCoords(activeHotspot.x, activeHotspot.y, rect.width, rect.height);
const hX = coords.x;
const hY = coords.y;
const radius = 75; // scope lens size
ctx.save();
// 3a. Clip drawing context inside a circle
ctx.beginPath();
ctx.arc(hX, hY, radius, 0, Math.PI * 2);
ctx.clip();
// 3b. Draw background image inside the clipped circle with grayscale and high contrast filters
ctx.filter = 'grayscale(1) contrast(1.6) brightness(0.8)';
const imgRatio = bgImage.width / bgImage.height;
const containerRatio = rect.width / rect.height;
let scaledW, scaledH, offsetX, offsetY;
if (containerRatio > imgRatio) {
scaledW = rect.width;
scaledH = rect.width / imgRatio;
offsetX = 0;
offsetY = (rect.height - scaledH) / 2;
} else {
scaledW = rect.height * imgRatio;
scaledH = rect.height;
offsetX = (rect.width - scaledW) / 2;
offsetY = 0;
}
ctx.drawImage(bgImage, offsetX, offsetY, scaledW, scaledH);
ctx.filter = 'none';
// 3c. Overlay thermal color mapping via 'color' global composite operation
ctx.globalCompositeOperation = 'color';
const thermalGrad = ctx.createRadialGradient(hX, hY, 2, hX, hY, radius);
thermalGrad.addColorStop(0, '#ffffff'); // Center (white hot)
thermalGrad.addColorStop(0.18, '#ffff00'); // Yellow
thermalGrad.addColorStop(0.4, '#ff4400'); // Red-Orange
thermalGrad.addColorStop(0.72, '#a000ff'); // Purple
thermalGrad.addColorStop(1.0, '#0022ff'); // Blue (cool background)
ctx.fillStyle = thermalGrad;
ctx.beginPath();
ctx.arc(hX, hY, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 3d. Draw HUD lens scope borders and reticle details
ctx.strokeStyle = activeHotspot.type === 'mixed' ? '#f97316' : '#ef4444';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(hX, hY, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = activeHotspot.type === 'mixed' ? 'rgba(249, 115, 22, 0.4)' : 'rgba(239, 68, 68, 0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(hX - radius - 10, hY); ctx.lineTo(hX + radius + 10, hY);
ctx.moveTo(hX, hY - radius - 10); ctx.lineTo(hX, hY + radius + 10);
ctx.stroke();
ctx.beginPath();
ctx.arc(hX, hY, 8, 0, Math.PI * 2);
ctx.stroke();
// Print temperature floating label
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 10px monospace';
ctx.shadowColor = '#000000';
ctx.shadowBlur = 4;
ctx.fillText(`${activeHotspot.temp.toFixed(1)}°C`, hX - 22, hY - radius - 6);
ctx.shadowBlur = 0;
}
// 4. RENDER HIGH-FIDELITY BORDERLESS PRPD GRAPH POPUP BOX ON HOVER (For prpd and mixed hotspots)
const hasAnyActive = !!activeHotspot;
const hasPrpdActive = activeHotspot && (activeHotspot.type === 'prpd' || activeHotspot.type === 'mixed');
if (hasPrpdActive) {
activePrpdHotspotRef.current = activeHotspot;
}
const targetPrpdOpacity = hasAnyActive ? 1 : 0;
prpdOpacityRef.current += (targetPrpdOpacity - prpdOpacityRef.current) * 0.15;
// Modulate audio volume based on hotspot opacity (representing lock on active hotspots)
if (masterGainRef.current && audioCtxRef.current) {
masterGainRef.current.gain.value = prpdOpacityRef.current * 0.8;
// Auto-resume if the browser suspended the context due to inactivity/silence
if (prpdOpacityRef.current > 0.05 && audioCtxRef.current.state === 'suspended') {
audioCtxRef.current.resume().catch(() => {});
}
}
if (prpdOpacityRef.current > 0.01 && hasPrpdActive && activePrpdHotspotRef.current) {
const hs = activePrpdHotspotRef.current;
const coords = getImageCoverCoords(hs.x, hs.y, rect.width, rect.height);
const hX = coords.x;
const hY = coords.y;
const opacity = prpdOpacityRef.current;
const boxW = 220;
const boxH = 140;
// Position PRPD box next to the hotspot/lens to avoid overlapping
const radius = (hs.type === 'mixed' || hs.type === 'thermal') ? 75 : 32;
const spacing = 15;
let bx;
if (hX + radius + spacing + boxW < rect.width) {
bx = hX + radius + spacing;
} else if (hX - radius - spacing - boxW > 0) {
bx = hX - radius - spacing - boxW;
} else {
bx = Math.max(12, rect.width - boxW - 12);
}
const by = Math.max(12, Math.min(rect.height - boxH - 12, hY - boxH / 2));
ctx.save();
ctx.shadowColor = hs.type === 'mixed' ? `rgba(249, 115, 22, ${0.45 * opacity})` : `rgba(34, 211, 238, ${0.45 * opacity})`;
ctx.shadowBlur = 12;
ctx.fillStyle = `rgba(6, 15, 30, ${0.88 * opacity})`;
// Borderless (no stroke border) round rect background
ctx.beginPath();
ctx.roundRect(bx, by, boxW, boxH, 8);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;
ctx.font = 'bold 8.5px monospace';
ctx.fillText(`[${hs.telemetry}] PRPD PARTIAL DISCHARGE`, bx + 10, by + 15);
ctx.fillStyle = hs.type === 'mixed' ? `rgba(249, 115, 22, ${opacity})` : `rgba(34, 211, 238, ${opacity})`;
ctx.font = '7.5px monospace';
ctx.fillText(`LEVEL: ${hs.db} dB | DISTANCE: ${hs.dist.toFixed(1)} m`, bx + 10, by + 26);
const gx = bx + 32;
const gy = by + 34;
const gw = boxW - 42;
const gh = boxH - 54;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.12 * opacity})`;
ctx.lineWidth = 1;
ctx.strokeRect(gx, gy, gw, gh);
ctx.strokeStyle = `rgba(255, 255, 255, ${0.06 * opacity})`;
ctx.lineWidth = 0.5;
ctx.setLineDash([2, 3]);
for (let v = 1; v <= 3; v++) {
const vx = gx + (gw / 4) * v;
ctx.beginPath(); ctx.moveTo(vx, gy); ctx.lineTo(vx, gy + gh); ctx.stroke();
}
for (let h = 1; h <= 3; h++) {
const hy = gy + (gh / 4) * h;
ctx.beginPath(); ctx.moveTo(gx, hy); ctx.lineTo(gx + gw, hy); ctx.stroke();
}
ctx.setLineDash([]);
ctx.fillStyle = `rgba(255, 255, 255, ${0.5 * opacity})`;
ctx.font = '6.5px monospace';
ctx.fillText('0°', gx - 2, gy + gh + 10);
ctx.fillText('180°', gx + gw / 2 - 8, gy + gh + 10);
ctx.fillText('360°', gx + gw - 12, gy + gh + 10);
ctx.fillText('Amp', gx - 22, gy + 4);
ctx.fillText('(pC)', gx - 22, gy + 11);
ctx.fillText('100', gx - 16, gy + 10);
ctx.fillText('50', gx - 12, gy + gh / 2 + 3);
ctx.fillText('0', gx - 8, gy + gh);
ctx.strokeStyle = `rgba(255, 255, 255, ${0.15 * opacity})`;
ctx.lineWidth = 1;
ctx.beginPath();
const sineOffset = frame * 0.03;
for (let px = 0; px <= gw; px++) {
const phase = (px / gw) * Math.PI * 2;
const py = (gy + gh / 2) + Math.sin(phase + sineOffset) * (gh * 0.38);
if (px === 0) ctx.moveTo(gx + px, py);
else ctx.lineTo(gx + px, py);
}
ctx.stroke();
let prpdLcgSeedVal = 0;
for (let i = 0; i < hs.telemetry.length; i++) prpdLcgSeedVal += hs.telemetry.charCodeAt(i);
let prpdLcg = 777 + prpdLcgSeedVal;
const lcgNext = () => {
prpdLcg = (prpdLcg * 1664525 + 1013904223) % 4294967296;
return prpdLcg / 4294967296;
};
const dotCount = 330;
for (let d = 0; d < dotCount; d++) {
const isPosHalf = lcgNext() > 0.5;
const centerPhase = isPosHalf ? 50 : 230;
const u1 = lcgNext();
const u2 = lcgNext();
const randNorm1 = Math.sqrt(-2.0 * Math.log(u1)) * Math.sin(2.0 * Math.PI * u2);
const u3 = lcgNext();
const u4 = lcgNext();
const randNorm2 = Math.sqrt(-2.0 * Math.log(u3)) * Math.sin(2.0 * Math.PI * u4);
let phaseVal = centerPhase + randNorm1 * 20;
if (phaseVal < 0) phaseVal += 360;
if (phaseVal > 360) phaseVal -= 360;
let magVal = 55 + randNorm2 * 14;
if (magVal < 2) magVal = 2;
if (magVal > 98) magVal = 98;
const shimmerX = Math.sin(frame * 0.08 + d) * 1.2;
const shimmerY = Math.cos(frame * 0.08 + d) * 1.2;
const finalPhase = phaseVal + shimmerX;
const finalMag = magVal + shimmerY;
const plotX = gx + (finalPhase / 360) * gw;
const plotY = (gy + gh) - (finalMag / 100) * gh;
if (plotX >= gx && plotX <= gx + gw && plotY >= gy && plotY <= gy + gh) {
const targetPhase = isPosHalf ? 50 : 230;
const dX = (finalPhase - targetPhase) / 20;
const dY = (finalMag - 55) / 14;
const distToCenter = Math.hypot(dX, dY);
let dotColor = `rgba(34, 211, 238, ${opacity})`;
if (distToCenter < 0.6) {
dotColor = `rgba(239, 68, 68, ${opacity})`;
} else if (distToCenter < 1.1) {
dotColor = `rgba(249, 115, 22, ${opacity})`;
} else if (distToCenter < 1.6) {
dotColor = `rgba(234, 179, 8, ${opacity})`;
}
ctx.fillStyle = dotColor;
ctx.fillRect(plotX, plotY, 1.2, 1.2);
}
}
ctx.restore();
}
// 4.5. GLITCH OVERLAY (triggered on hotspot enter)
if (glitchRef.current.active) {
const g = glitchRef.current;
g.frames++;
if (g.frames >= g.totalFrames) {
glitchRef.current.active = false;
} else {
const glitchProgress = g.frames / g.totalFrames;
const intensity = (1 - glitchProgress) * (activeHotspot ? 1 : 0.5);
const lineCount = Math.floor(6 + Math.random() * 8);
for (let gl = 0; gl < lineCount; gl++) {
if (Math.random() > 0.45) continue;
const gy2 = Math.random() * rect.height;
const glH = 1 + Math.random() * 4;
const glW = 20 + Math.random() * (rect.width * 0.55);
const glX = Math.random() * (rect.width - glW);
const shift = (Math.random() - 0.5) * 18;
// Chromatic aberration style glitch slice
ctx.save();
ctx.globalAlpha = intensity * (0.12 + Math.random() * 0.22);
if (bgImage) {
const imgRatio = bgImage.width / bgImage.height;
const containerRatio = rect.width / rect.height;
let scaledW, scaledH, offsetX, offsetY;
if (containerRatio > imgRatio) {
scaledW = rect.width; scaledH = rect.width / imgRatio;
offsetX = 0; offsetY = (rect.height - scaledH) / 2;
} else {
scaledW = rect.height * imgRatio; scaledH = rect.height;
offsetX = (rect.width - scaledW) / 2; offsetY = 0;
}
ctx.beginPath();
ctx.rect(glX, gy2, glW, glH);
ctx.clip();
// Red channel shift
ctx.globalCompositeOperation = 'screen';
ctx.filter = 'saturate(3) hue-rotate(0deg) brightness(1.5)';
ctx.drawImage(bgImage, offsetX + shift, offsetY, scaledW, scaledH);
ctx.filter = 'none';
}
ctx.restore();
// Solid glitch bar
ctx.save();
ctx.globalAlpha = intensity * 0.08;
ctx.fillStyle = Math.random() > 0.5 ? '#22d3ee' : '#ef4444';
ctx.fillRect(glX, gy2, glW, glH);
ctx.restore();
}
// Scanline flicker
ctx.save();
ctx.globalAlpha = intensity * 0.06;
ctx.fillStyle = '#000';
for (let sl = 0; sl < rect.height; sl += 4) {
ctx.fillRect(0, sl, rect.width, 1);
}
ctx.restore();
}
}
// 5. HUD LASER CROSSHAIR SCANNER (Shown when mouse is actively hovering)
if (mouseRef.current.active) {
const cX = crosshairRef.current.x;
const cY = crosshairRef.current.y;
if (activeHotspot) {
ctx.strokeStyle = activeHotspot.type === 'thermal' ? 'rgba(239, 68, 68, 0.4)' : (activeHotspot.type === 'mixed' ? 'rgba(249, 115, 22, 0.4)' : 'rgba(34, 211, 238, 0.4)');
ctx.lineWidth = 1;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(mouseRef.current.x, mouseRef.current.y);
ctx.lineTo(cX, cY);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.strokeStyle = activeHotspot ? (activeHotspot.type === 'thermal' ? '#ef4444' : (activeHotspot.type === 'mixed' ? '#f97316' : '#22d3ee')) : '#38bdf8';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(cX, cY, 32, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.beginPath();
ctx.arc(cX, cY, 44, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = activeHotspot ? (activeHotspot.type === 'thermal' ? 'rgba(239, 68, 68, 0.5)' : (activeHotspot.type === 'mixed' ? 'rgba(249, 115, 22, 0.5)' : 'rgba(34, 211, 238, 0.5)')) : 'rgba(56, 189, 248, 0.5)';
ctx.beginPath();
ctx.moveTo(cX - 40, cY); ctx.lineTo(cX - 12, cY);
ctx.moveTo(cX + 12, cY); ctx.lineTo(cX + 40, cY);
ctx.moveTo(cX, cY - 40); ctx.lineTo(cX, cY - 12);
ctx.moveTo(cX, cY + 12); ctx.lineTo(cX, cY + 40);
ctx.stroke();
if (activeHotspot) {
const b = 10;
const r = 26;
ctx.strokeStyle = activeHotspot.type === 'thermal' ? '#ef4444' : (activeHotspot.type === 'mixed' ? '#f97316' : '#22d3ee');
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cX - r, cY - r + b); ctx.lineTo(cX - r, cY - r); ctx.lineTo(cX - r + b, cY - r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cX + r, cY - r + b); ctx.lineTo(cX + r, cY - r); ctx.lineTo(cX + r - b, cY - r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cX - r, cY + r - b); ctx.lineTo(cX - r, cY + r); ctx.lineTo(cX - r + b, cY + r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cX + r, cY + r - b); ctx.lineTo(cX + r, cY + r); ctx.lineTo(cX + r - b, cY + r); ctx.stroke();
ctx.fillStyle = activeHotspot.type === 'thermal' ? '#ef4444' : (activeHotspot.type === 'mixed' ? '#f97316' : '#22d3ee');
ctx.font = 'bold 9px monospace';
ctx.fillText("LOCK TRG", cX - 22, cY + 42);
}
}
drawMiniCharts(activeHotspot);
animationFrameId.current = requestAnimationFrame(render);
};
const drawMiniCharts = (activeHs) => {
// 1. PRPD mini chart
const prpd = miniPrpdRef.current;
if (prpd) {
const pCtx = prpd.getContext('2d');
pCtx.clearRect(0, 0, prpd.width, prpd.height);
const w = prpd.width;
const h = prpd.height;
const baseline = h / 2;
pCtx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
pCtx.lineWidth = 1;
pCtx.beginPath(); pCtx.moveTo(0, baseline); pCtx.lineTo(w, baseline); pCtx.stroke();
pCtx.strokeStyle = activeHs && (activeHs.type === 'prpd' || activeHs.type === 'mixed') ? 'rgba(34, 211, 238, 0.7)' : 'rgba(255, 255, 255, 0.15)';
pCtx.lineWidth = 1.2;
pCtx.beginPath();
for (let x = 0; x < w; x++) {
const phi = (x / w) * Math.PI * 2 * 2;
const y = baseline + Math.sin(phi) * (h * 0.35);
if (x === 0) pCtx.moveTo(x, y);
else pCtx.lineTo(x, y);
}
pCtx.stroke();
pCtx.fillStyle = '#22d3ee';
if (activeHs && (activeHs.type === 'prpd' || activeHs.type === 'mixed')) {
for (let i = 0; i < 30; i++) {
const x = Math.random() * w;
const phi = (x / w) * Math.PI * 2 * 2;
const sineVal = Math.sin(phi);
if (Math.abs(sineVal) > 0.45) {
const y = baseline + (sineVal * (h * 0.35)) - (Math.random() * h * 0.35) * Math.sign(sineVal);
pCtx.fillRect(x, y, 1, 1);
}
}
} else {
pCtx.fillStyle = 'rgba(255, 255, 255, 0.2)';
for (let i = 0; i < 4; i++) {
pCtx.fillRect(Math.random() * w, h - 2 - Math.random() * 3, 0.8, 0.8);
}
}
}
// 2. Temp sparkline chart
const temp = miniTempRef.current;
if (temp) {
const tCtx = temp.getContext('2d');
tCtx.clearRect(0, 0, temp.width, temp.height);
const w = temp.width;
const h = temp.height;
const history = tempHistoryRef.current;
const maxVal = 120;
const minVal = 10;
if (history.length > 1) {
tCtx.strokeStyle = activeHs && (activeHs.type === 'thermal' || activeHs.type === 'mixed') ? (activeHs.type === 'mixed' ? '#f97316' : '#ef4444') : '#10b981';
tCtx.lineWidth = 1.5;
tCtx.beginPath();
for (let i = 0; i < history.length; i++) {
const x = (i / (history.length - 1)) * w;
const pct = (history[i] - minVal) / (maxVal - minVal);
const y = h - (pct * h * 0.8 + h * 0.1);
if (i === 0) tCtx.moveTo(x, y);
else tCtx.lineTo(x, y);
}
tCtx.stroke();
}
}
};
render();
return () => {
if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current);
};
}, [scene, mode, hoveredHotspot, addLog, containerSize, bgImage, visibleHotspots]);
// Handle slide changes manually
const selectScene = (idx) => {
setIsAutoPlaying(false);
setActiveScene(idx);
};
const handleMouseMove = (e) => {
const rect = containerRef.current.getBoundingClientRect();
mouseRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
active: true
};
};
const handleMouseLeave = () => {
mouseRef.current.active = false;
setHoveredHotspot(null);
};
const ambientTemp = 24.1 + Math.sin(Date.now() * 0.001) * 0.4;
const displayTemp = hoveredHotspot ? hoveredHotspot.temp : ambientTemp;
const displayDb = hoveredHotspot ? hoveredHotspot.db : 10;
const displayDist = hoveredHotspot ? hoveredHotspot.dist : null;
return (
{e.target.style.opacity='1';e.target.style.filter='grayscale(0)';}} onMouseOut={e=>{e.target.style.opacity='.85';e.target.style.filter='grayscale(.5)';}}/>
))}
{ko ? kd : ed}
))}