const SPECTRUM_VERSION = Date.now();
function SpectrumGraph() {
const { locale, t } = useI18n();
const sectionRef = React.useRef(null);
const iframeRef = React.useRef(null);
const readyRef = React.useRef(false);
const lastTRef = React.useRef(-1);
const [isMobile, setIsMobile] = React.useState(
typeof window !== 'undefined' && window.matchMedia('(max-width: 820px)').matches
);
React.useEffect(() => {
const mq = window.matchMedia('(max-width: 820px)');
const onChange = () => setIsMobile(mq.matches);
mq.addEventListener?.('change', onChange);
return () => mq.removeEventListener?.('change', onChange);
}, []);
// Eight Sleep–style scroll-pin: the section is N×100vh tall, a sticky child
// pins the iframe to the viewport, and we drive horizontal pan via the
// page's own scroll progress. No body lock, no wheel hijack — native scroll.
React.useEffect(() => {
if (isMobile) return;
const onReady = (e) => {
const d = e.data;
if (d && d.type === 'spectrum-ready') {
readyRef.current = true;
send(); // sync immediately
}
};
window.addEventListener('message', onReady);
let raf = 0;
const send = () => {
const sec = sectionRef.current;
const iframe = iframeRef.current;
if (!sec || !iframe || !iframe.contentWindow) return;
const r = sec.getBoundingClientRect();
const vh = window.innerHeight;
const span = sec.offsetHeight - vh;
let t = span > 0 ? (-r.top) / span : 0;
if (t < 0) t = 0; else if (t > 1) t = 1;
// Intro/outro hold: keep scene 0 pinned and readable for the first
// ~18% of the scroll before panning starts, and let the final scene
// rest for the last 8% before the section unpins.
const HOLD_IN = 0.18, HOLD_OUT = 0.08;
t = (t - HOLD_IN) / (1 - HOLD_IN - HOLD_OUT);
if (t < 0) t = 0; else if (t > 1) t = 1;
// Avoid spamming postMessage when t hasn't changed meaningfully.
if (Math.abs(t - lastTRef.current) < 0.0008) return;
lastTRef.current = t;
try {
iframe.contentWindow.postMessage({ type: 'pan-progress', t }, '*');
} catch (_) {}
};
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(send);
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
// Initial push in case iframe loads after first scroll
send();
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
window.removeEventListener('message', onReady);
cancelAnimationFrame(raf);
};
}, [isMobile]);
if (isMobile) return ;
return (
{/* Pinned intro — kept visible above the graph on desktop (mirrors mobile) */}
{t('spectrum.heroTitle').replace(/\n/g, ' ')}
{t('spectrum.heroBody')}
);
}
// ── Mobile fallback ─────────────────────────────────────────────────────
// ── Mobile sticky-pinned spectrum (Eight Sleep-style scroll-driven) ─────
// Marker dot stays fixed at center; curves pan beneath it as you scroll.
const SPECTRUM_SCENES_MOBILE = [
{nm:380, zone:null, ztag:null,
kicker:'SOLADUO Overview',
title:'What is SunLike White?',
body:"SunLike White of Seoul Semiconductor has a similar spectrum as natural sunlight spectrum. It may give you benefits to reduce eye fatigue and improve focus, learning efficiency and better sleep quality. SunLike White may create a comfortable and natural ambiance in your space."},
{nm:415, zone:'D', ztag:{l:'D',bg:'rgba(160,80,255,.25)',fg:'#c880ff'},
kicker:'Opsin 5 · 415nm · Myopia Prevention',
title:'SunLike White : Human Centric Lighting',
body:"White spectrum designed to aid concentration and balance circadian rhythm. The closest spectrum to natural daylight."},
{nm:450, zone:'A', ztag:{l:'A',bg:'rgba(100,130,255,.25)',fg:'#9ab3ff'},
kicker:'Blue Hazard · 450nm',
title:'The Limitation of Standard Lighting: The 400nm Blue Hazard',
body:"Conventional white LEDs emit an abnormally high spike of blue light at the 400nm wavelength. This artificial peak causes eye strain and disrupts your sleep cycle. SunLike White eliminates this jagged curve, delivering a smooth, balanced spectrum that perfectly matches natural sunlight."},
{nm:490, zone:'B', ztag:{l:'B',bg:'rgba(50,200,170,.2)',fg:'#32c8b4'},
kicker:'Melanopic Zone · 480nm',
title:'Restoring the True Colors of Nature',
body:"Standard lighting loses crucial wavelengths in the 470-490nm range, significantly limiting its color rendition. By fully restoring this missing spectrum without peaks or valleys, SunLike White reveals the innate true colors of your surroundings across 99 distinct shades with over 95% fidelity."},
{nm:530, zone:null, ztag:null,
kicker:'True Colors · 470–500nm',
title:'Restoring the True Colors of Nature',
body:"Standard lighting loses crucial wavelengths in the 470-490nm range, significantly limiting its color rendition. By fully restoring this missing spectrum without peaks or valleys, SunLike White reveals the innate true colors of your surroundings across 99 distinct shades with over 95% fidelity."},
{nm:570, zone:null, ztag:null,
kicker:'Broad Peak Rising · 530–570nm',
title:'Improve learning capability',
body:"SunLike White rises smoothly from the valley to its broad peak region — mimicking natural sunlight's gradual increase. Increases alertness and learning effect during the daytime. Standard LED took a jagged path to reach the same region."},
{nm:650, zone:null, ztag:null,
kicker:'CRI-97 · Rf 95+',
title:'Accurate color rendition & Certified Safe',
body:"By tracking natural sunlight across the full visible spectrum, SunLike White achieves CRI-97. Effective on eye health for growing children. SunLike White declines gracefully after 650nm, unlike Standard LED's jagged history."},
{nm:720, zone:null, ztag:null,
kicker:'5개국 10개 글로벌 혁신 기술상',
title:'Certified Safe Lighting',
body:"We have won 10 Global Innovation Technology Awards in 5 countries. SunLike White establishes a comfortable environment in any space — certified safe and healthy by global authorities."}
];
// Curve data (matches desktop spectrum.html exactly)
// Desktop coords: x = (nm-380)*18 + 150, y = 800 - intensity*7.6
const _SPEC_SUN_DT = [[150,724],[510,656],[780,595],[1050,566],[1410,526],[1950,473],[2310,444],[2850,405],[3210,382],[3570,351],[3750,337],[4110,306],[4470,192],[4650,200],[4830,222],[5010,238],[5190,268],[5370,316],[5550,382],[5730,481],[5910,587],[6090,663],[6270,724],[6450,762],[6630,785],[8000,800]];
const _SPEC_STD_DT = [[150,762],[510,738],[870,682],[1050,604],[1200,440],[1290,356],[1410,40],[1500,250],[1620,620],[1770,740],[1950,762],[2130,747],[2310,709],[2670,595],[2850,542],[3210,420],[3570,329],[3750,306],[3930,299],[4110,299],[4290,316],[4470,344],[4650,374],[5010,435],[5550,572],[5910,663],[6270,724],[6810,768],[8000,790]];
const _SPEC_NAT_DT = [[150,648],[510,610],[780,572],[1050,534],[1410,496],[1950,450],[2310,420],[2850,382],[3210,359],[3570,329],[3750,314],[4110,283],[4650,245],[5010,222],[5550,192],[5910,177],[6270,169],[6810,169],[8000,196]];
// Mobile SVG geometry — wider curve space than viewBox so curves can pan.
const _SPEC_NM_LO = 380, _SPEC_NM_HI = 780, _SPEC_NM_END = 720;
const _SPEC_VIEW_W = 360, _SPEC_H = 200;
const _SPEC_CENTER_X = 180;
const _SPEC_PAD_T = 14, _SPEC_PAD_B = 30;
const _SPEC_plotH = _SPEC_H - _SPEC_PAD_T - _SPEC_PAD_B;
const _SPEC_baseY = _SPEC_PAD_T + _SPEC_plotH;
// Curve-space x: 4 units per nm → spectrum 380-780nm spans 0-1600.
const _SPEC_NM_PER_UNIT = 4;
const _SPEC_nmX = (nm) => (nm - _SPEC_NM_LO) * _SPEC_NM_PER_UNIT;
function _specConvert(pts){
return pts.map(([x,y])=>{
const nm = (x - 150) / 18 + _SPEC_NM_LO;
const intensity = (800 - y) / 7.6;
return {nm, intensity, mx:_SPEC_nmX(nm), my:_SPEC_PAD_T + (1 - intensity/100) * _SPEC_plotH};
});
}
const _SPEC_SUN = _specConvert(_SPEC_SUN_DT);
const _SPEC_STD = _specConvert(_SPEC_STD_DT);
const _SPEC_NAT = _specConvert(_SPEC_NAT_DT);
function _specIntAt(data, nm){
if(nm<=data[0].nm) return data[0].intensity;
for(let i=0;i [d.mx, d.my]);
if(P.length<2) return '';
let d = `M${P[0][0].toFixed(2)},${P[0][1].toFixed(2)}`;
for(let k=0;k ({ ...scene, ...sceneTexts[i] }));
const outerRef = React.useRef(null);
const panRef = React.useRef(null); // group that pans horizontally
const sunDotRef = React.useRef(null);
const sunHaloRef = React.useRef(null);
const natDotRef = React.useRef(null);
const stdDotRef = React.useRef(null);
const zoneRefs = { D: React.useRef(null), A: React.useRef(null), B: React.useRef(null), C: React.useRef(null) };
const progRef = React.useRef(null);
const hintRef = React.useRef(null);
const [activeIdx, setActiveIdx] = React.useState(0);
React.useEffect(() => {
const outer = outerRef.current;
if (!outer) return;
let raf = 0, lastIdx = -1;
const update = () => {
raf = 0;
const r = outer.getBoundingClientRect();
const vh = window.innerHeight;
const span = outer.offsetHeight - vh;
let rawT = span > 0 ? (-r.top) / span : 0;
if (rawT < 0) rawT = 0; else if (rawT > 1) rawT = 1;
if (rawT > 0.02 && hintRef.current) hintRef.current.classList.add('hide');
// Intro/outro hold: first scene stays readable before panning starts;
// last scene rests briefly before the section unpins.
const HOLD_IN = 0.16, HOLD_OUT = 0.08;
let t = (rawT - HOLD_IN) / (1 - HOLD_IN - HOLD_OUT);
if (t < 0) t = 0; else if (t > 1) t = 1;
// focus nm — what the central marker is "looking at"
const nm = _SPEC_NM_LO + t * (_SPEC_NM_END - _SPEC_NM_LO);
const curveX = _SPEC_nmX(nm);
const offset = curveX - _SPEC_CENTER_X;
const sy = _specYAt(_SPEC_SUN, nm);
const ny = _specYAt(_SPEC_NAT, nm);
const dy = _specYAt(_SPEC_STD, nm);
// Pan the curves so the focus point sits at center.
if (panRef.current) panRef.current.setAttribute('transform', `translate(${-offset.toFixed(2)}, 0)`);
// Update fixed-center marker dot y positions (cx stays = CENTER_X).
if (sunDotRef.current) { sunDotRef.current.setAttribute('cy', sy); }
if (sunHaloRef.current) { sunHaloRef.current.setAttribute('cy', sy); }
if (natDotRef.current) { natDotRef.current.setAttribute('cy', ny); }
if (stdDotRef.current) { stdDotRef.current.setAttribute('cy', dy); }
// Progress bar tracks raw scroll so the user gets feedback even
// during the hold zones (otherwise the section feels "stuck").
if (progRef.current) progRef.current.style.width = (rawT * 100) + '%';
// Nearest scene
let bestI = 0, bestD = Infinity;
for (let i = 0; i < SPECTRUM_SCENES_MOBILE.length; i++) {
const d = Math.abs(SPECTRUM_SCENES_MOBILE[i].nm - nm);
if (d < bestD) { bestD = d; bestI = i; }
}
if (bestI !== lastIdx) {
lastIdx = bestI;
setActiveIdx(bestI);
const scene = SPECTRUM_SCENES_MOBILE[bestI];
['D','A','B','C'].forEach(k => {
if (zoneRefs[k].current) zoneRefs[k].current.setAttribute('opacity', scene.zone === k ? '1' : '0');
});
}
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
cancelAnimationFrame(raf);
};
}, []);
return (