|
| 1 | +// /genome — endless procedural double helix. EXPERIMENTAL, standalone (shares nothing |
| 2 | +// with /cpic's CpicCockpit), so it can never break the working pharmacogenomics cockpit. |
| 3 | +// |
| 4 | +// The idea (the user's): the OGAR GUID address space is billions of slots |
| 5 | +// (HEEL·HIP·TWIG cascade); CPIC fills almost none of it. So this is NOT a sized mesh — |
| 6 | +// it is an ENDLESS scaffold that IS the address space, with the sparse real CPIC genes |
| 7 | +// lighting up loci in it. Cheap because it is pure REPETITION: one instanced sugar bead |
| 8 | +// and one instanced rung, both PLACED BY A FUNCTION of the integer step (golden-angle |
| 9 | +// twist + linear rise), drawn only for a window of steps around a scroll offset — so the |
| 10 | +// strand is infinite while the instance count is bounded and constant. No baked geometry, |
| 11 | +// no forced shape. Zoom descends the 16-ary cascade: each tier subdivides the spacing ×16 |
| 12 | +// (self-similar — "scale = the next cascade level"), the literal fractal of the radix tree. |
| 13 | +// |
| 14 | +// Next step (documented, not done here): light loci from the real CPIC graph via |
| 15 | +// POST /api/cpic/reason instead of the hardcoded gene table below. |
| 16 | +import { useEffect, useRef, useState } from 'react'; |
| 17 | +import * as THREE from 'three'; |
| 18 | + |
| 19 | +const PAGE_BG = 0x05070d; |
| 20 | +const WINDOW = 240; // base-pairs instanced at once (the visible turns); constant |
| 21 | +const RISE = 0.34; // vertical gap between successive base-pairs |
| 22 | +const RADIUS = 1.0; // helix radius (strand centre to axis) |
| 23 | +const TWIST = 2.399963; // radians/step = the GOLDEN ANGLE → the pattern never exactly |
| 24 | +// repeats (aperiodic, the "fractal endlessness" — same most-irrational step the φ-spiral uses). |
| 25 | +const TAU = Math.PI * 2; |
| 26 | + |
| 27 | +// The four bases as a deterministic repeating palette (A·T·G·C). Real DNA isn't periodic, |
| 28 | +// but the SCAFFOLD is: the base at a step is a pure function of the step index, so the same |
| 29 | +// address always paints the same rung — addressability without storage. |
| 30 | +const BASE_RGB = [ |
| 31 | + [0xff, 0x6b, 0x57], // A — coral |
| 32 | + [0xf2, 0xc9, 0x4c], // T — amber |
| 33 | + [0x4c, 0xa6, 0xf2], // G — azure |
| 34 | + [0x57, 0xd9, 0x8e], // C — mint |
| 35 | +]; |
| 36 | +const baseAt = (step: number) => ((step * 2654435761) >>> 0) & 3; // cheap hash → 0..3, stable per step |
| 37 | + |
| 38 | +// Sparse CPIC loci: real pharmacogenes lit up at fixed addresses in the endless scaffold. |
| 39 | +// The gene list is pulled LIVE from GET /api/cpic/catalog; this canonical CPIC level-A set |
| 40 | +// is only the fallback when the endpoint is absent (old deploy) so /genome still renders. |
| 41 | +const FALLBACK_GENES = ['CYP2D6', 'CYP2C19', 'CYP2C9', 'CYP3A5', 'TPMT', 'DPYD', 'SLCO1B1', |
| 42 | + 'UGT1A1', 'NUDT15', 'VKORC1', 'CYP4F2', 'G6PD', 'HLA-B', 'IFNL3', 'CFTR', 'RYR1']; |
| 43 | +type Locus = { step: number; gene: string }; |
| 44 | +// Each gene gets a STABLE address from a hash of its name (FNV-1a) → a step in [0,4096). |
| 45 | +// Same gene ⇒ same locus forever (addressability without storage), spread across the tier. |
| 46 | +function lociFrom(genes: string[]): Locus[] { |
| 47 | + const seen = new Map<number, string>(); |
| 48 | + const out: Locus[] = []; |
| 49 | + for (const g of genes) { |
| 50 | + let hsh = 2166136261; |
| 51 | + for (let i = 0; i < g.length; i++) { hsh ^= g.charCodeAt(i); hsh = Math.imul(hsh, 16777619); } |
| 52 | + let step = (hsh >>> 0) % 4096; |
| 53 | + while (seen.has(step)) step = (step + 1) % 4096; // linear-probe the rare collision |
| 54 | + seen.set(step, g); out.push({ step, gene: g }); |
| 55 | + } |
| 56 | + return out; |
| 57 | +} |
| 58 | + |
| 59 | +function labelSprite(text: string): THREE.Sprite { |
| 60 | + const c = document.createElement('canvas'); c.width = 256; c.height = 64; |
| 61 | + const x = c.getContext('2d')!; |
| 62 | + x.fillStyle = 'rgba(8,12,20,0.0)'; x.fillRect(0, 0, 256, 64); |
| 63 | + x.font = 'bold 34px ui-monospace, monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; |
| 64 | + x.fillStyle = '#eaf2ff'; x.shadowColor = '#000'; x.shadowBlur = 6; x.fillText(text, 128, 32); |
| 65 | + const t = new THREE.CanvasTexture(c); t.anisotropy = 4; |
| 66 | + const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, transparent: true, depthWrite: false })); |
| 67 | + s.scale.set(0.9, 0.225, 1); return s; |
| 68 | +} |
| 69 | + |
| 70 | +function mount(container: HTMLDivElement, scroll: { current: number }, density: { current: number }, |
| 71 | + dirty: { current: boolean }, locusByStep: Map<number, string>): () => void { |
| 72 | + let w = container.clientWidth || window.innerWidth, h = container.clientHeight || window.innerHeight; |
| 73 | + const scene = new THREE.Scene(); scene.background = new THREE.Color(PAGE_BG); |
| 74 | + scene.fog = new THREE.Fog(PAGE_BG, 6, 16); // ends fade into the dark → reads as endless |
| 75 | + const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 100); camera.position.set(0, 0, 6.2); |
| 76 | + const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| 77 | + renderer.setSize(w, h); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| 78 | + container.appendChild(renderer.domElement); |
| 79 | + scene.add(new THREE.AmbientLight(0xffffff, 0.55)); |
| 80 | + const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(2, 3, 4); scene.add(key); |
| 81 | + |
| 82 | + // ── instanced geometry: 2 strands of sugar beads + 1 set of base-pair rungs ── |
| 83 | + const bead = new THREE.SphereGeometry(0.085, 10, 8); |
| 84 | + const beadMat = new THREE.MeshStandardMaterial({ roughness: 0.5, metalness: 0.1 }); |
| 85 | + const strandA = new THREE.InstancedMesh(bead, beadMat, WINDOW); |
| 86 | + const strandB = new THREE.InstancedMesh(bead, beadMat, WINDOW); |
| 87 | + strandA.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(WINDOW * 3), 3); |
| 88 | + strandB.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(WINDOW * 3), 3); |
| 89 | + const rung = new THREE.CylinderGeometry(0.028, 0.028, 1, 6); rung.rotateZ(Math.PI / 2); // lie along X |
| 90 | + const rungMat = new THREE.MeshStandardMaterial({ roughness: 0.6 }); |
| 91 | + const rungs = new THREE.InstancedMesh(rung, rungMat, WINDOW); |
| 92 | + rungs.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(WINDOW * 3), 3); |
| 93 | + scene.add(strandA, strandB, rungs); |
| 94 | + const geneOf: (string | null)[] = new Array(WINDOW).fill(null); // instance k → gene (for picking) |
| 95 | + |
| 96 | + // a small pool of reusable locus labels (only the few visible in the window) |
| 97 | + const LABELS = 10; |
| 98 | + const labels: { sprite: THREE.Sprite; text: string }[] = []; |
| 99 | + for (let i = 0; i < LABELS; i++) { const s = labelSprite(''); s.visible = false; scene.add(s); labels.push({ sprite: s, text: '' }); } |
| 100 | + |
| 101 | + const m = new THREE.Matrix4(), q = new THREE.Quaternion(), pos = new THREE.Vector3(), scl = new THREE.Vector3(1, 1, 1); |
| 102 | + const cA = new THREE.Color(), cB = new THREE.Color(), cR = new THREE.Color(); |
| 103 | + |
| 104 | + // place the window. step k (0..WINDOW) maps to ABSOLUTE address = base + k/dens, so as |
| 105 | + // `scroll` advances the same WINDOW instances slide along an infinite strand (no realloc), |
| 106 | + // and as `density` grows the spacing subdivides ×16 per tier (the fractal cascade zoom). |
| 107 | + function layout() { |
| 108 | + const dens = density.current; // base-pairs per unit step (16^tierFrac) |
| 109 | + const base = scroll.current; |
| 110 | + const rise = RISE / dens, twist = TWIST; // finer tiers pack tighter (self-similar) |
| 111 | + let li = 0; |
| 112 | + for (let k = 0; k < WINDOW; k++) { |
| 113 | + const step = base + k; // integer address in this tier |
| 114 | + const ang = step * twist; |
| 115 | + const y = (k - WINDOW / 2) * rise; |
| 116 | + const ax = Math.cos(ang) * RADIUS, az = Math.sin(ang) * RADIUS; |
| 117 | + // strand A bead |
| 118 | + pos.set(ax, y, az); m.compose(pos, q, scl); strandA.setMatrixAt(k, m); |
| 119 | + // strand B bead (opposite side) |
| 120 | + pos.set(-ax, y, -az); m.compose(pos, q, scl); strandB.setMatrixAt(k, m); |
| 121 | + // rung: midpoint, scaled to span 2·RADIUS, rotated to point along the strand pair |
| 122 | + const addr = ((step % 4096) + 4096) % 4096; |
| 123 | + const isLoc = locusByStep.has(addr); |
| 124 | + geneOf[k] = isLoc ? locusByStep.get(addr)! : null; |
| 125 | + const b = baseAt(step), col = BASE_RGB[b]; |
| 126 | + cA.setRGB(col[0] / 255, col[1] / 255, col[2] / 255); |
| 127 | + strandA.setColorAt(k, cA.clone().multiplyScalar(0.8)); |
| 128 | + strandB.setColorAt(k, cB.setRGB(col[2] / 255, col[1] / 255, col[0] / 255).multiplyScalar(0.8)); |
| 129 | + rungPlace(k, ax, y, az); |
| 130 | + if (isLoc) cR.setRGB(1, 1, 1); else cR.copy(cA).multiplyScalar(0.65); |
| 131 | + rungs.setColorAt(k, cR); |
| 132 | + // locus label |
| 133 | + if (isLoc && li < LABELS) { |
| 134 | + const g = geneOf[k]!; |
| 135 | + const L = labels[li++]; if (L.text !== g) { L.sprite.material.map = labelSprite(g).material.map; L.text = g; } |
| 136 | + L.sprite.position.set(0, y + 0.16, 0); L.sprite.visible = true; |
| 137 | + } |
| 138 | + } |
| 139 | + for (; li < LABELS; li++) labels[li].sprite.visible = false; |
| 140 | + strandA.instanceMatrix.needsUpdate = strandB.instanceMatrix.needsUpdate = rungs.instanceMatrix.needsUpdate = true; |
| 141 | + strandA.instanceColor!.needsUpdate = strandB.instanceColor!.needsUpdate = rungs.instanceColor!.needsUpdate = true; |
| 142 | + } |
| 143 | + const rq = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0); |
| 144 | + function rungPlace(k: number, ax: number, y: number, az: number) { |
| 145 | + pos.set(0, y, 0); |
| 146 | + const len = Math.hypot(ax, az) * 2 || 1e-3; |
| 147 | + rq.setFromUnitVectors(up, new THREE.Vector3(ax, 0, az).normalize()); // align rung to the A↔B chord |
| 148 | + m.compose(pos, rq, new THREE.Vector3(1, len, 1)); rungs.setMatrixAt(k, m); |
| 149 | + } |
| 150 | + |
| 151 | + // controls: drag = orbit, wheel = descend/ascend tiers (fractal zoom), auto-drift = endless travel |
| 152 | + // click (no drag) on a lit locus = hand off to the working /cpic reasoner for that gene. |
| 153 | + let az = 0, el = 0.0, dragging = false, moved = 0, px = 0, py = 0, dist = 6.2; |
| 154 | + const ray = new THREE.Raycaster(); const ndc = new THREE.Vector2(); |
| 155 | + const pick = (e: PointerEvent): string | null => { |
| 156 | + const r = el2.getBoundingClientRect(); |
| 157 | + ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); |
| 158 | + ray.setFromCamera(ndc, camera); |
| 159 | + const hit = ray.intersectObject(rungs)[0]; |
| 160 | + return hit && hit.instanceId != null ? geneOf[hit.instanceId] : null; |
| 161 | + }; |
| 162 | + const onDown = (e: PointerEvent) => { dragging = true; moved = 0; px = e.clientX; py = e.clientY; }; |
| 163 | + const onUp = (e: PointerEvent) => { |
| 164 | + dragging = false; |
| 165 | + if (moved < 5) { const g = pick(e); if (g) window.location.assign(`/cpic?gene=${encodeURIComponent(g)}`); } |
| 166 | + }; |
| 167 | + const onMove = (e: PointerEvent) => { |
| 168 | + if (!dragging) return; |
| 169 | + moved += Math.abs(e.clientX - px) + Math.abs(e.clientY - py); |
| 170 | + az -= (e.clientX - px) * 0.005; el = Math.max(-1.2, Math.min(1.2, el + (e.clientY - py) * 0.005)); px = e.clientX; py = e.clientY; dirty.current = true; |
| 171 | + }; |
| 172 | + const onWheel = (e: WheelEvent) => { e.preventDefault(); density.current = Math.max(1, Math.min(4096, density.current * (1 - Math.sign(e.deltaY) * 0.06))); dirty.current = true; }; |
| 173 | + const el2 = renderer.domElement; |
| 174 | + el2.addEventListener('pointerdown', onDown); window.addEventListener('pointerup', onUp); |
| 175 | + window.addEventListener('pointermove', onMove); el2.addEventListener('wheel', onWheel, { passive: false }); |
| 176 | + const onResize = () => { w = container.clientWidth; h = container.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); dirty.current = true; }; |
| 177 | + window.addEventListener('resize', onResize); |
| 178 | + |
| 179 | + let raf = 0, lastScroll = NaN, lastDens = NaN; |
| 180 | + const tick = () => { |
| 181 | + raf = requestAnimationFrame(tick); |
| 182 | + scroll.current += 0.06; // gentle endless travel up the strand |
| 183 | + if (scroll.current !== lastScroll || density.current !== lastDens) { layout(); lastScroll = scroll.current; lastDens = density.current; } |
| 184 | + camera.position.set(dist * Math.cos(el) * Math.sin(az), dist * Math.sin(el), dist * Math.cos(el) * Math.cos(az)); |
| 185 | + camera.lookAt(0, 0, 0); |
| 186 | + renderer.render(scene, camera); |
| 187 | + dirty.current = false; |
| 188 | + }; |
| 189 | + tick(); |
| 190 | + return () => { |
| 191 | + cancelAnimationFrame(raf); |
| 192 | + el2.removeEventListener('pointerdown', onDown); window.removeEventListener('pointerup', onUp); |
| 193 | + window.removeEventListener('pointermove', onMove); el2.removeEventListener('wheel', onWheel); |
| 194 | + window.removeEventListener('resize', onResize); |
| 195 | + bead.dispose(); rung.dispose(); beadMat.dispose(); rungMat.dispose(); renderer.dispose(); |
| 196 | + if (el2.parentElement === container) container.removeChild(el2); |
| 197 | + }; |
| 198 | +} |
| 199 | + |
| 200 | +export default function GenomeHelix() { |
| 201 | + const ref = useRef<HTMLDivElement>(null); |
| 202 | + const scroll = useRef(0); |
| 203 | + const density = useRef(1); |
| 204 | + const dirty = useRef(true); |
| 205 | + const [genes, setGenes] = useState<string[] | null>(null); // null = still loading the catalog |
| 206 | + const [live, setLive] = useState(false); // true = real /api/cpic/catalog |
| 207 | + const [, force] = useState(0); |
| 208 | + |
| 209 | + // pull the REAL CPIC gene catalogue; fall back to the canonical list if the endpoint is |
| 210 | + // absent (old deploy) so /genome always renders. Same graceful-degradation as /helix LOD. |
| 211 | + useEffect(() => { |
| 212 | + let cancelled = false; |
| 213 | + fetch('/api/cpic/catalog') |
| 214 | + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) |
| 215 | + .then((j: { genes?: string[] }) => { |
| 216 | + if (cancelled) return; |
| 217 | + const gs = (j.genes ?? []).filter(Boolean); |
| 218 | + if (gs.length) { setGenes(gs); setLive(true); } else { setGenes(FALLBACK_GENES); } |
| 219 | + }) |
| 220 | + .catch(() => { if (!cancelled) setGenes(FALLBACK_GENES); }); |
| 221 | + return () => { cancelled = true; }; |
| 222 | + }, []); |
| 223 | + |
| 224 | + useEffect(() => { |
| 225 | + const c = ref.current; if (!c || !genes) return; |
| 226 | + const locusByStep = new Map(lociFrom(genes).map((l) => [l.step, l.gene])); |
| 227 | + return mount(c, scroll, density, dirty, locusByStep); |
| 228 | + }, [genes]); |
| 229 | + // light re-render so the tier readout updates as you zoom |
| 230 | + useEffect(() => { const id = setInterval(() => force((n) => n + 1), 250); return () => clearInterval(id); }, []); |
| 231 | + const tier = Math.log(density.current) / Math.log(16); |
| 232 | + return ( |
| 233 | + <div style={{ position: 'fixed', inset: 0, background: `#${PAGE_BG.toString(16).padStart(6, '0')}` }}> |
| 234 | + <div ref={ref} style={{ position: 'absolute', inset: 0 }} /> |
| 235 | + <div style={{ position: 'absolute', top: 12, left: 16, color: '#cdd9e5', font: '13px ui-monospace, monospace', pointerEvents: 'none' }}> |
| 236 | + <div style={{ color: '#fff', fontSize: 15 }}>/genome — endless pharmacogenomic helix</div> |
| 237 | + <div style={{ opacity: 0.62, marginTop: 3, maxWidth: 360 }}> |
| 238 | + {genes ? `${WINDOW} instanced base-pairs · golden-angle scaffold · ${genes.length} CPIC gene loci ${live ? 'lit (live /api/cpic)' : 'lit (fallback list)'} · tier ${tier.toFixed(2)}` |
| 239 | + : 'loading CPIC gene catalogue…'} |
| 240 | + </div> |
| 241 | + <div style={{ opacity: 0.4, marginTop: 4 }}>drag = orbit · wheel = descend the 16-ary cascade · click a lit gene → /cpic</div> |
| 242 | + </div> |
| 243 | + </div> |
| 244 | + ); |
| 245 | +} |
0 commit comments