339 lines
18 KiB
JavaScript
339 lines
18 KiB
JavaScript
/* ============================================================
|
|
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 = '<p class="panel-subtitle">데이터 로드 실패: ' + e + '</p>'; return; }
|
|
buildControls(); buildLegend();
|
|
if (!window.THREE) {
|
|
el('net-wrap').innerHTML = '<p class="panel-subtitle" style="padding:24px">3D 라이브러리(Three.js) 로드 실패 — 인터넷 연결 확인. (네트워크 데이터는 정상)</p>';
|
|
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 => `<option value="${d}">${diseases[d]}</option>`).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 => `<option value="${k}">${data.scenarios[k].label.split('·')[1].trim()}</option>`).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 =>
|
|
`<span class="net-leg"><i style="background:${HEX[g]}"></i>${data.groups[g]}</span>`).join('')
|
|
+ '<span class="net-leg" style="margin-left:auto">🖱 드래그=회전·휠=확대 · <b>단백질 클릭 → 아틀라스 상세</b></span>';
|
|
}
|
|
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 };
|
|
})();
|