/* ============================================================ network.js — 단백질 상호작용 네트워크 + 모낭 (3D, 시간축) Three.js(WebGL) 로 단백질 신호망과 모낭을 3D 렌더. 마우스 회전, 조명, 같은 시간 슬라이더로 기전→단백질→모낭 변화를 동기 표시. 데이터: data/network_dynamics.json ============================================================ */ (function () { 'use strict'; let data = null, done = false, scen = 'AGA|finasteride', tIdx = 0, playing = false, timer = null; const el = id => document.getElementById(id); const GCOL = { androgen: 0xc8401f, wnt_antag: 0xd9683f, bmp: 0x9a6a12, wnt: 0x1f5d52, shh: 0x2f8f7f, dp: 0x2f63c8, hfsc: 0x7a55a8, hair: 0x1f6d3a, immune: 0xb3361b, }; const HEX = { androgen: '#c8401f', wnt_antag: '#d9683f', bmp: '#9a6a12', wnt: '#1f5d52', shh: '#2f8f7f', dp: '#2f63c8', hfsc: '#7a55a8', hair: '#1f6d3a', immune: '#b3361b' }; // 그룹별 깊이(z) — 드라이버 앞, 산출 뒤 const GZ = { androgen: 2.6, immune: 2.6, wnt_antag: 1.3, bmp: 0.6, wnt: 0, shh: -0.7, dp: -1.5, hfsc: -2.2, hair: -2.9 }; // 단백질 → UniProt 가속(AlphaFold 구조 로드용) const ACC = { AR: 'P10275', SRD5A2: 'P31213', IFNG: 'P01579', CD8A: 'P01732', 'HLA-DQB1': 'P01920', CXCL10: 'P02778', DKK1: 'O94907', SFRP1: 'Q8N474', BMP4: 'P12644', ID1: 'P41134', WNT10B: 'O00744', CTNNB1: 'P35222', LEF1: 'Q9UJU2', AXIN2: 'Q9Y2T1', SHH: 'Q15465', GLI1: 'P08151', SOX2: 'P48431', VCAN: 'P13611', ALPL: 'P05186', KRT15: 'P19012', CD34: 'P28906', LGR5: 'O75473', KRT85: 'P78386', KRT35: 'Q92764', }; let net = null, foll = null, raf = null; // 3D 컨텍스트 let mode = 'sphere', nglStage = null, nglComps = {}, nglBuilt = false; async function init() { if (done) return; done = true; try { data = await fetch('data/network_dynamics.json').then(r => r.json()); } catch (e) { el('net-wrap').innerHTML = '

데이터 로드 실패: ' + e + '

'; return; } buildControls(); buildLegend(); if (!window.THREE) { el('net-wrap').innerHTML = '

3D 라이브러리(Three.js) 로드 실패 — 인터넷 연결 확인. (네트워크 데이터는 정상)

'; return; } setupNet(); setupFoll(); window.addEventListener('resize', onResize); update(); animate(); } function curScen() { return data.scenarios[scen]; } function months() { return data.months; } function actOf(nid) { return curScen().activity[nid][tIdx]; } function gact(group) { const ids = data.nodes.filter(n => n.group === group).map(n => n.id); if (!ids.length) return 0; return ids.reduce((s, id) => s + actOf(id), 0) / ids.length; } function hashJit(id) { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 1000; return (h / 1000 - 0.5) * 1.2; } // 단백질 클릭 → 아틀라스 탭 상세(있으면) / 없으면 AlphaFold EBI function openProteinSafe(gene) { const inCat = window.Store && Store.proteinByGene && Store.proteinByGene[gene]; if (inCat && typeof window.openProtein === 'function') window.openProtein(gene); else if (ACC[gene]) window.open('https://alphafold.ebi.ac.uk/entry/' + ACC[gene], '_blank'); } let _down = null; // 드래그 구분 // ---------- 컨트롤 ---------- function buildControls() { const diseases = { AGA: '남성형 탈모(AGA)', AA: '원형 탈모(AA)' }; const dz = el('net-disease'); dz.innerHTML = Object.keys(diseases).map(d => ``).join(''); dz.value = scen.split('|')[0]; dz.onchange = () => fillTreatments(dz.value); fillTreatments(dz.value); const sl = el('net-time'); sl.min = 0; sl.max = months().length - 1; sl.value = tIdx; sl.oninput = () => { stop(); tIdx = +sl.value; update(); }; el('net-play').onclick = togglePlay; const mb = el('net-mode'); if (mb) mb.onclick = toggleMode; } function toggleMode() { if (typeof NGL === 'undefined') { el('net-status').textContent = 'NGL(구조 뷰어) 미로딩 — 인터넷 확인'; return; } mode = mode === 'sphere' ? 'structure' : 'sphere'; const mb = el('net-mode'); if (mode === 'structure') { mb.textContent = '⚪ 구체(추상)'; mb.classList.add('on'); el('net-canvas').style.display = 'none'; el('net-ngl').style.display = 'block'; if (!nglBuilt) buildStructureNet(); } else { mb.textContent = '🧬 AlphaFold 구조'; mb.classList.remove('on'); el('net-ngl').style.display = 'none'; el('net-canvas').style.display = 'block'; } update(); } function fillTreatments(dz) { const keys = Object.keys(data.scenarios).filter(k => k.startsWith(dz + '|')); const tr = el('net-treat'); tr.innerHTML = keys.map(k => ``).join(''); scen = keys.includes(scen) ? scen : keys[Math.min(1, keys.length - 1)]; tr.value = scen; tr.onchange = () => { stop(); scen = tr.value; update(); }; } function buildLegend() { el('net-legend').innerHTML = Object.keys(data.groups).map(g => `${data.groups[g]}`).join('') + '🖱 드래그=회전·휠=확대 · 단백질 클릭 → 아틀라스 상세'; } function togglePlay() { playing ? stop() : start(); } function start() { playing = true; el('net-play').textContent = '⏸ 정지'; timer = setInterval(() => { tIdx++; if (tIdx >= months().length) { tIdx = months().length - 1; stop(); return; } el('net-time').value = tIdx; update(); }, 720); } function stop() { playing = false; el('net-play').textContent = '▶ 재생'; if (timer) clearInterval(timer); timer = null; } // ---------- 3D 공통 ---------- function mkCtx(canvas, camPos) { const w = canvas.clientWidth || 400, h = canvas.clientHeight || 360; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); renderer.setSize(w, h, false); const scene = new THREE.Scene(); const cam = new THREE.PerspectiveCamera(45, w / h, 0.1, 100); cam.position.set(camPos[0], camPos[1], camPos[2]); const ctrl = new THREE.OrbitControls(cam, renderer.domElement); ctrl.enableDamping = true; ctrl.dampingFactor = 0.08; ctrl.enablePan = false; scene.add(new THREE.AmbientLight(0xffffff, 0.62)); const d1 = new THREE.DirectionalLight(0xfff4e6, 0.85); d1.position.set(5, 8, 9); scene.add(d1); const d2 = new THREE.DirectionalLight(0xcfe0ff, 0.32); d2.position.set(-6, -3, -5); scene.add(d2); return { renderer, scene, cam, ctrl, w, h }; } function edgeCyl(a, b, color, radius, op) { const dir = new THREE.Vector3().subVectors(b, a); const len = dir.length(); const geo = new THREE.CylinderGeometry(radius, radius, len, 6); const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: .35, transparent: true, opacity: op, roughness: .6 }); const m = new THREE.Mesh(geo, mat); m.position.copy(a).addScaledVector(dir, .5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize()); return m; } // ---------- 네트워크 3D ---------- function nodePos(n) { return new THREE.Vector3((n.x - 50) / 50 * 8, (50 - n.y) / 50 * 5, (GZ[n.group] || 0) + hashJit(n.id)); } function setupNet() { net = mkCtx(el('net-canvas'), [0, 0, 18]); net.ctrl.target.set(0, 0, 0); net.ctrl.autoRotate = true; net.ctrl.autoRotateSpeed = 0.5; net.nodes = {}; net.edges = []; data.nodes.forEach(n => { const geo = new THREE.SphereGeometry(0.42, 22, 22); const mat = new THREE.MeshStandardMaterial({ color: GCOL[n.group], emissive: GCOL[n.group], emissiveIntensity: .2, roughness: .35, metalness: .1 }); const mesh = new THREE.Mesh(geo, mat); mesh.position.copy(nodePos(n)); mesh.userData.id = n.id; net.scene.add(mesh); net.nodes[n.id] = mesh; // 라벨 스프라이트 const sp = makeLabel(n.label); sp.position.copy(mesh.position).add(new THREE.Vector3(0, 0.85, 0)); net.scene.add(sp); }); data.edges.forEach(e => { const a = nodePos(data.nodes.find(n => n.id === e.from)), b = nodePos(data.nodes.find(n => n.id === e.to)); if (!a || !b) return; const col = e.type === 'inhibit' ? 0xb3361b : 0x1f6d3a; const cyl = edgeCyl(a, b, col, 0.05, 0.5); cyl.userData = e; net.scene.add(cyl); net.edges.push(cyl); }); // 클릭 → 단백질 상세 (드래그 제외) const cv = el('net-canvas'); cv.style.cursor = 'pointer'; cv.addEventListener('pointerdown', e => { _down = [e.clientX, e.clientY]; }); cv.addEventListener('pointerup', e => { if (mode !== 'sphere' || !net || !_down) return; if (Math.hypot(e.clientX - _down[0], e.clientY - _down[1]) > 6) { _down = null; return; } _down = null; const r = cv.getBoundingClientRect(); const m = new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); const ray = new THREE.Raycaster(); ray.setFromCamera(m, net.cam); const hits = ray.intersectObjects(Object.values(net.nodes)); if (hits.length && hits[0].object.userData.id) openProteinSafe(hits[0].object.userData.id); }); } function makeLabel(text) { const cv = document.createElement('canvas'); const s = 128; cv.width = 256; cv.height = 64; const g = cv.getContext('2d'); g.font = '700 30px Pretendard, sans-serif'; g.fillStyle = '#1b1a16'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(text, 128, 34); const tex = new THREE.CanvasTexture(cv); tex.anisotropy = 4; const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); sp.scale.set(2.4, 0.6, 1); return sp; } function updateNet() { if (!net) return; data.nodes.forEach(n => { const a = actOf(n.id), m = net.nodes[n.id]; const s = 0.55 + 1.05 * a; m.scale.setScalar(s); m.material.emissiveIntensity = 0.12 + 0.95 * a; m.material.opacity = 1; m.material.transparent = false; }); net.edges.forEach(c => { const sa = actOf(c.userData.from); c.material.opacity = 0.1 + 0.6 * sa; }); } // ---------- AlphaFold 구조 네트워크 (NGL) ---------- const KPOS = 17, SSCALE = 0.5; function posK(n) { return [(n.x - 50) / 50 * 8 * KPOS, (50 - n.y) / 50 * 5 * KPOS, ((GZ[n.group] || 0) + hashJit(n.id)) * KPOS]; } function structUrls(acc) { return ['../digital_twin/data/structures/' + acc + '_AF.pdb', 'https://alphafold.ebi.ac.uk/files/AF-' + acc + '-F1-model_v4.pdb']; } function buildStructureNet() { nglBuilt = true; const load = el('net-ngl-load'); load.style.display = 'flex'; nglStage = new NGL.Stage('net-ngl', { backgroundColor: '#f2ece1' }); el('net-ngl').style.cursor = 'pointer'; const compId = {}; // uuid → gene nglStage.signals.clicked.add(pp => { if (pp && pp.component && compId[pp.component.uuid]) openProteinSafe(compId[pp.component.uuid]); }); const nodesA = data.nodes.filter(n => ACC[n.id]); const total = nodesA.length; let loaded = 0; load.textContent = 'AlphaFold 구조 로딩 0/' + total; // 엣지 + 라벨(정적) const shape = new NGL.Shape('edges'); data.edges.forEach(e => { const a = data.nodes.find(n => n.id === e.from), b = data.nodes.find(n => n.id === e.to); if (!a || !b || !ACC[a.id] || !ACC[b.id]) return; const col = e.type === 'inhibit' ? [0.7, 0.21, 0.11] : [0.12, 0.43, 0.23]; try { shape.addCylinder(posK(a), posK(b), col, 0.9); } catch (er) {} }); nodesA.forEach(n => { const p = posK(n); try { shape.addText([p[0], p[1] + 16, p[2]], [0.1, 0.09, 0.08], 15, n.label); } catch (er) {} }); try { nglStage.addComponentFromObject(shape).addRepresentation('buffer'); } catch (er) {} // 구조 nodesA.forEach(n => { const urls = structUrls(ACC[n.id]); const place = comp => { const r = comp.addRepresentation('cartoon', { color: HEX[n.group], smoothSheet: true }); let cx = 0, cy = 0, cz = 0; try { const c = comp.structure.center; cx = c.x; cy = c.y; cz = c.z; } catch (e) {} comp.setScale(SSCALE); const p = posK(n); comp.setPosition([p[0] - SSCALE * cx, p[1] - SSCALE * cy, p[2] - SSCALE * cz]); compId[comp.uuid] = n.id; nglComps[n.id] = { comp, repr: r }; }; const fin = () => { loaded++; load.textContent = 'AlphaFold 구조 로딩 ' + loaded + '/' + total; if (loaded >= total) { load.style.display = 'none'; try { nglStage.autoView(600); } catch (e) {} updateStructureNet(); } }; nglStage.loadFile(urls[0], { ext: 'pdb' }).then(c => { place(c); fin(); }) .catch(() => nglStage.loadFile(urls[1], { ext: 'pdb' }).then(c => { place(c); fin(); }).catch(fin)); }); } function updateStructureNet() { data.nodes.forEach(n => { const c = nglComps[n.id]; if (!c) return; const a = actOf(n.id); try { c.repr.setParameters({ opacity: 0.45 + 0.55 * a }); } catch (e) {} }); } // ---------- 모낭 3D ---------- function setupFoll() { foll = mkCtx(el('net-follicle'), [0, -0.5, 12]); foll.ctrl.target.set(0, -1, 0); foll.ctrl.autoRotate = true; foll.ctrl.autoRotateSpeed = 0.8; // 피부층(반투명 디스크) const layer = (y, h, r, col, op) => { const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 40), new THREE.MeshStandardMaterial({ color: col, transparent: true, opacity: op, roughness: .9 })); m.position.y = y; return m; }; foll.scene.add(layer(2.1, 0.5, 4, 0xeac9a6, 0.55)); // 표피 foll.scene.add(layer(-0.3, 4.2, 4, 0xe3cdb0, 0.34)); // 진피 foll.scene.add(layer(-3.1, 1.4, 4, 0xf0dd9c, 0.4)); // 피하지방 foll.group = new THREE.Group(); foll.scene.add(foll.group); } function buildFollicle(hair, dp, immune, disease) { const g = new THREE.Group(); const surf = 2.0; const depth = disease === 'AA' ? (4.6 + 0.6 * hair) : (2.6 + 3.4 * dp); const bulbY = surf - depth; const bulbR = 0.45 + 0.7 * (disease === 'AA' ? 0.8 : dp); const neckR = 0.22 + 0.18 * (disease === 'AA' ? 0.8 : dp); // 외초 — LatheGeometry(프로파일 회전) const pts = [ new THREE.Vector2(0.02, 0), new THREE.Vector2(bulbR, 0.35 * depth * 0.3 + 0.2), new THREE.Vector2(bulbR * 0.7, depth * 0.45), new THREE.Vector2(neckR, depth * 0.7), new THREE.Vector2(neckR, depth), ]; const sheath = new THREE.Mesh(new THREE.LatheGeometry(pts, 36), new THREE.MeshStandardMaterial({ color: 0xe6c9a4, transparent: true, opacity: 0.55, side: THREE.DoubleSide, roughness: .8 })); sheath.position.y = bulbY; g.add(sheath); // 진피유두 DP const dpR = 0.22 + 0.5 * dp; const dpm = new THREE.Mesh(new THREE.SphereGeometry(dpR, 20, 20), new THREE.MeshStandardMaterial({ color: 0xc98b5e, emissive: 0x6b3a1e, emissiveIntensity: .25, roughness: .5 })); dpm.position.y = bulbY + bulbR * 0.5; g.add(dpm); // 모간 hair shaft const hairR = disease === 'AA' ? 0.14 : (0.04 + 0.26 * hair); const hairAbove = 0.4 + 3.2 * hair; const shaftBottom = bulbY + bulbR * 0.4, shaftTop = surf + hairAbove; const aaBroken = (disease === 'AA' && hair < 0.45); const top = aaBroken ? surf - 0.2 + 0.4 * hair : shaftTop; const len = Math.max(0.2, top - shaftBottom); const hairCol = disease === 'AA' ? (hair > 0.5 ? 0x3f3020 : 0x6b5640) : (hair > 0.5 ? 0x402f1c : 0x8a7660); const shaft = new THREE.Mesh(new THREE.CylinderGeometry(hairR, hairR * 1.2, len, 12), new THREE.MeshStandardMaterial({ color: hairCol, roughness: .55, metalness: .15 })); shaft.position.y = shaftBottom + len / 2; g.add(shaft); // 피지선 const sg = new THREE.Mesh(new THREE.SphereGeometry(0.3, 14, 14), new THREE.MeshStandardMaterial({ color: 0xdcc878, transparent: true, opacity: .7, roughness: .8 })); sg.position.set(neckR + 0.35, surf - depth * 0.28, 0); g.add(sg); // 면역세포(AA) if (disease === 'AA') { const n = Math.round(immune * 14); for (let i = 0; i < n; i++) { const a = i / Math.max(n, 1) * 6.283, rr = bulbR + 0.45 + (i % 3) * 0.18; const c = new THREE.Mesh(new THREE.SphereGeometry(0.13, 10, 10), new THREE.MeshStandardMaterial({ color: 0xb3361b, emissive: 0x7a1c0c, emissiveIntensity: .4 })); c.position.set(Math.cos(a) * rr, bulbY + bulbR * 0.5 + Math.sin(a * 1.7) * 0.5, Math.sin(a) * rr); g.add(c); } } return g; } function updateFoll() { if (!foll) return; const disease = scen.split('|')[0]; while (foll.group.children.length) { const c = foll.group.children.pop(); c.traverse && c.traverse(o => { o.geometry && o.geometry.dispose(); o.material && o.material.dispose(); }); foll.group.remove(c); } foll.group.add(buildFollicle(gact('hair'), gact('dp'), gact('immune'), disease)); let state; if (disease === 'AA') { const im = gact('immune'), h = gact('hair'); state = im > 0.55 ? '면역공격 · 모발탈락' : (h > 0.6 ? '재생 완료' : '재생 중'); } else { const h = gact('hair'); state = h < 0.32 ? '소형화 모낭(vellus)' : (h < 0.65 ? '회복 중' : '정상모(terminal)'); } el('net-foll-state').textContent = state; } // ---------- 갱신/루프 ---------- function update() { const m = months()[tIdx]; el('net-month').textContent = m < 1 ? Math.round(m * 30) + '일' : m + '개월'; const S = curScen(); el('net-status').textContent = S.label + ' — 표적: ' + (S.targets && S.targets.length ? S.targets.map(t => data.groups[t]).join(', ') : '없음(무치료)'); if (mode === 'structure' && nglBuilt) updateStructureNet(); else updateNet(); updateFoll(); } function onResize() { [net, foll].forEach(c => { if (!c) return; const cv = c.renderer.domElement; const w = cv.clientWidth, h = cv.clientHeight; if (w && h) { c.renderer.setSize(w, h, false); c.cam.aspect = w / h; c.cam.updateProjectionMatrix(); } }); } function animate() { raf = requestAnimationFrame(animate); if (net) { net.ctrl.update(); net.renderer.render(net.scene, net.cam); } if (foll) { foll.ctrl.update(); foll.renderer.render(foll.scene, foll.cam); } } window.NetworkTab = { init }; })();