Skip to content

Commit f334859

Browse files
authored
Merge pull request #67 from AdaWorldAPI/claude/q2-genome-helix
/genome: endless procedural double-helix viewer over the live CPIC gene catalogue
2 parents e32429d + 360a386 commit f334859

8 files changed

Lines changed: 284 additions & 11 deletions

File tree

Dockerfile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,16 @@ COPY --from=frontend /build/dist/ /build/q2/cockpit/dist/
5858
# 20260629b re-bake: teeth → skeleton + per-vessel diameter boundary (no stray fat
5959
# branches). Pulled under its stamped name, served same-origin AS body.soa.gz so /body
6060
# picks it up; the old body.soa.gz stays in the release untouched.
61-
RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629b.soa.gz \
61+
RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629c.soa.gz \
6262
-o /build/q2/cockpit/dist/body.soa.gz \
6363
&& ls -lh /build/q2/cockpit/dist/body.soa.gz
6464

6565
# Same for the /helix wire: one SoA (BSO2 ver 6) = F16 pos + a canonical Signed360
6666
# NORMAL column in the same struct-of-arrays. Same-origin for the same CORS reason;
6767
# named by cockpit/public/body.manifest.json (helix_latest). Stays in the release.
68-
RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629b.v6helix.soa.gz \
69-
-o /build/q2/cockpit/dist/body.20260629b.v6helix.soa.gz \
70-
&& ls -lh /build/q2/cockpit/dist/body.20260629b.v6helix.soa.gz
68+
RUN curl -fSL https://github.com/AdaWorldAPI/q2/releases/download/fma-body-soa-v3-v1/body.20260629c.v6helix.soa.gz \
69+
-o /build/q2/cockpit/dist/body.20260629c.v6helix.soa.gz \
70+
&& ls -lh /build/q2/cockpit/dist/body.20260629c.v6helix.soa.gz
7171

7272
# Sibling deps — clone from GitHub
7373
# graph-flow stub is local (crates/stubs/graph-flow), no rs-graph-llm needed

cockpit/public/body.manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"helix_latest": "body.20260629b.v6helix.soa.gz",
3-
"note": "20260629b re-bake from soa_v2: teeth reclassified into the skeleton (layer 4) + per-vessel slicer-fill diameter boundary (no stray fat branches at bends). BSO2 ver 6 = F16 positions + Signed360 NORMAL column + HXFL floor trailer; helixbake (real lance-graph::helix::encode_signed). Decode: rim r=sinθ -> int8 normal at load, Gouraud per-vertex shading. Published to fma-body-soa-v3-v1; Dockerfile pulls same-origin.",
2+
"helix_latest": "body.20260629c.v6helix.soa.gz",
3+
"note": "20260629c re-emit from soa_v2 (geometry identical to 20260629b): 39 connective structures (ligaments / tendons / interosseous membranes / fascia / retinacula / iliotibial tract) reclassified out of the ORGAN and SKIN layers into the now-live CONNECTIVE layer 7 — they were FMA-filed under /viscera/solid_organ/ligament_organ, so the is_a walk tagged them viscus->organ and they floated in the organ view as tan limb-shaped strays (interosseous membrane of leg/forearm, calcaneal tendon, long plantar ligament). Carries the 20260629b fixes (teeth->skeleton, per-vessel slicer-fill diameter). BSO2 ver 6 = F16 pos + Signed360 NORMAL + HXFL trailer; Gouraud per-vertex shading. Published to fma-body-soa-v3-v1; Dockerfile pulls same-origin.",
44
"verts": 4283525
55
}

cockpit/src/CpicCockpit.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ function levelColor(level: string | null): string {
7171

7272
export function CpicCockpit() {
7373
const [catalog, setCatalog] = useState<Catalog>({ genes: [], drugs: [] });
74-
const [gene, setGene] = useState('CYP2C19');
74+
// honor a ?gene= deep-link (e.g. from /genome's locus click); falls back to the default.
75+
const [gene, setGene] = useState(() => new URLSearchParams(window.location.search).get('gene') || 'CYP2C19');
7576
const [input, setInput] = useState('*2/*2');
7677
const [drug, setDrug] = useState('clopidogrel');
7778
const [outcome, setOutcome] = useState<Outcome | null>(null);

cockpit/src/GenomeHelix.tsx

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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+
}

cockpit/src/main.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { TorsoMap } from './TorsoMap';
1515
import { FmaBody } from './FmaBody';
1616
import { BodyV3 } from './BodyV3';
1717
import BodyHelix from './BodyHelix';
18+
import GenomeHelix from './GenomeHelix';
1819
import { CpicCockpit } from './CpicCockpit';
1920
import { ReasoningPage } from './ReasoningPage';
2021
import { ErrorBoundary } from './components/ErrorBoundary';
@@ -116,6 +117,13 @@ createRoot(document.getElementById('root')!).render(
116117
via POST /api/cpic/reason (the standalone cpic crate). Additive, gene-first
117118
alternative to the organ-first /fma-body. */}
118119
<Route path="/cpic" element={<CpicCockpit />} />
120+
{/* /genome — EXPERIMENTAL endless procedural double helix (GenomeHelix.tsx,
121+
standalone so it can never break /cpic). The GUID address space is billions
122+
of slots; CPIC fills almost none — so this is an infinite golden-angle
123+
scaffold (one instanced base-pair placed by a function of the step, windowed)
124+
with the real pharmacogenes lit as sparse loci. Wheel descends the 16-ary
125+
cascade (self-similar). Next: feed loci from /api/cpic/reason. */}
126+
<Route path="/genome" element={<GenomeHelix />} />
119127
{/* The Palantir JSON-graph cockpit (221 aiwar nodes) stays reachable
120128
at /palantir and as the catch-all for its own sub-routes. */}
121129
<Route path="/palantir" element={<PalantirApp />} />

0 commit comments

Comments
 (0)