Skip to content

Commit 83be261

Browse files
committed
fix(brain): selection panel works + readable + sparse-data backfill + HUD stutter
Selection wiring switched from imperative-handle to React props. Hud is now stateless, BrainV2 holds selectedSlot/hoverSlot in React state and passes them as props. The earlier forwardRef + useImperativeHandle path silently failed to update the Hud's selected state on click, so the detail panel never rendered (only the small hover tooltip was visible). Detail panel rebuilt: bottom-right, 360px wide, full content (tier badge + node id + label + AGENT / TYPE / TIER / MEMBERS / RADIUS rows + footer hint). Larger fonts (17px label, 12px values), dividers, readable color contrast, glow shadow. HUD stutter fixed: the stats and ticker no longer go through React. BrainV2 owns DOM refs to the four stat <span>s and the ticker <div>; the rAF tick writes textContent directly via writeStats() and renderTicker(). Stats poll throttled to once per second AND skipped when values haven't changed. No React reconciliation per-frame. Tiers backfill: when actual data is sparse (e.g. only 2 clusters in the dump), the loose-memory tier expands to fill the leftover budget so the constellation still renders ~70-90 satellites. desiredTotal = decisions + clusters + loose; looseTarget grows to absorb the unused slots from the upper tiers. Camera: spotlight() now also pauseAutoRotate()s so the auto-orbit doesn't fight the camera ease during the 800ms ramp. Tests: 167 passed. Build: vite green.
1 parent 5b13dd8 commit 83be261

5 files changed

Lines changed: 220 additions & 107 deletions

File tree

Lines changed: 38 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,55 @@
1-
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react";
2-
3-
const TICKER_MAX = 5;
4-
const TICKER_TTL_MS = 6_000;
5-
6-
export const Hud = forwardRef(function Hud({ stats }, ref) {
7-
const [entries, setEntries] = useState([]);
8-
const [hover, setHover] = useState(null);
9-
const [selected, setSelected] = useState(null);
10-
const queueRef = useRef([]);
11-
const rafRef = useRef(null);
12-
13-
useImperativeHandle(ref, () => ({
14-
pushFiringEntry: (label) => {
15-
queueRef.current.push({ id: `${performance.now()}-${Math.random()}`, label, ts: performance.now() });
16-
if (rafRef.current != null) return;
17-
rafRef.current = requestAnimationFrame(() => {
18-
rafRef.current = null;
19-
const next = queueRef.current.splice(0, queueRef.current.length);
20-
if (!next.length) return;
21-
setEntries(prev => [...next, ...prev].slice(0, TICKER_MAX));
22-
});
23-
},
24-
setHover: (slot) => setHover(slot),
25-
setSelected: (slot) => setSelected(slot),
26-
}), []);
27-
28-
useEffect(() => {
29-
if (!entries.length) return undefined;
30-
const handle = setInterval(() => {
31-
const now = performance.now();
32-
setEntries(prev => prev.filter(e => now - e.ts < TICKER_TTL_MS));
33-
}, 1_000);
34-
return () => clearInterval(handle);
35-
}, [entries.length]);
1+
function tierLabel(tier) {
2+
if (tier === "decision") return "DECISION";
3+
if (tier === "cluster") return "CLUSTER";
4+
if (tier === "loose") return "MEMORY";
5+
return "NODE";
6+
}
367

8+
export function Hud({ hover, selected }) {
379
return (
3810
<>
39-
<div className="brain-v2-hud-strip">
40-
<span className="brain-v2-hud-stat"><span className="brain-v2-hud-label">NODES</span>{stats?.nodes ?? 0}</span>
41-
<span className="brain-v2-hud-stat"><span className="brain-v2-hud-label">CLUSTERS</span>{stats?.clusters ?? 0}</span>
42-
<span className="brain-v2-hud-stat"><span className="brain-v2-hud-label">DECISIONS</span>{stats?.decisions ?? 0}</span>
43-
<span className="brain-v2-hud-stat"><span className="brain-v2-hud-label">FIRING</span>{stats?.activeBeams ?? 0}</span>
44-
</div>
45-
<div className="brain-v2-ticker" aria-hidden="true">
46-
{entries.map(entry => (
47-
<div key={entry.id} className="brain-v2-ticker-line">{entry.label}</div>
48-
))}
49-
</div>
5011
{hover && !selected ? (
5112
<div className="brain-v2-tooltip">
52-
<div className="brain-v2-tooltip-tier">{hover.tier}</div>
13+
<div className="brain-v2-tooltip-tier">{tierLabel(hover.tier)}</div>
5314
<div className="brain-v2-tooltip-label">{hover.label}</div>
54-
{hover.tier === "cluster" ? (
55-
<div className="brain-v2-tooltip-meta">{hover.memberCount} members</div>
56-
) : null}
5715
</div>
5816
) : null}
5917
{selected ? (
60-
<div className="brain-v2-detail">
61-
<div className="brain-v2-detail-tier">{selected.tier}</div>
18+
<div className="brain-v2-detail" role="dialog" aria-label="Selected node">
19+
<div className="brain-v2-detail-head">
20+
<span className="brain-v2-detail-tier">{tierLabel(selected.tier)}</span>
21+
<span className="brain-v2-detail-id">{selected.id}</span>
22+
</div>
6223
<div className="brain-v2-detail-label">{selected.label}</div>
63-
<div className="brain-v2-detail-meta">
64-
<span>agent: {selected.agent}</span>
65-
{selected.tier === "cluster" ? <span>members: {selected.memberCount}</span> : null}
24+
<div className="brain-v2-detail-grid">
25+
<div className="brain-v2-detail-row">
26+
<span className="brain-v2-detail-key">AGENT</span>
27+
<span className="brain-v2-detail-val">{selected.agent || "—"}</span>
28+
</div>
29+
<div className="brain-v2-detail-row">
30+
<span className="brain-v2-detail-key">TYPE</span>
31+
<span className="brain-v2-detail-val">{selected.type || "—"}</span>
32+
</div>
33+
<div className="brain-v2-detail-row">
34+
<span className="brain-v2-detail-key">TIER</span>
35+
<span className="brain-v2-detail-val">{selected.tier}</span>
36+
</div>
37+
{selected.tier === "cluster" ? (
38+
<div className="brain-v2-detail-row">
39+
<span className="brain-v2-detail-key">MEMBERS</span>
40+
<span className="brain-v2-detail-val">{selected.memberCount}</span>
41+
</div>
42+
) : null}
43+
<div className="brain-v2-detail-row">
44+
<span className="brain-v2-detail-key">RADIUS</span>
45+
<span className="brain-v2-detail-val">{Math.round(selected.orbitRadius || 0)}u</span>
46+
</div>
6647
</div>
48+
<div className="brain-v2-detail-footer">right-click to deselect</div>
6749
</div>
6850
) : null}
6951
</>
7052
);
71-
});
53+
}
7254

7355
export default Hud;

desktop/cortex-control-center/src/brain-v2/Tiers.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ export function buildTiers(dump, options = {}) {
5151
const rawClusters = (dump?.clusters || dump?.crystals || []).slice(0, budget.clusters);
5252
const memories = (dump?.memories || []).slice();
5353
memories.sort((a, b) => (b?.score || 0) - (a?.score || 0));
54-
const looseMemories = memories.slice(0, budget.loose);
54+
// Backfill: compute how many memories we actually need to hit the budget.
55+
const desiredTotal = budget.decisions + budget.clusters + budget.loose;
56+
const usedSoFar = decisions.length + rawClusters.length;
57+
const looseTargetEarly = Math.max(budget.loose, desiredTotal - usedSoFar);
58+
const looseMemories = memories.slice(0, looseTargetEarly);
5559

5660
const decisionsLayout = decisions.map((node, index) => {
5761
const id = `decision-${node.id}`;
@@ -125,8 +129,12 @@ export function buildTiers(dump, options = {}) {
125129
};
126130
});
127131

132+
const usedDecisions = decisionsLayout.length;
133+
const usedClusters = clustersLayout.length;
134+
const looseTarget = Math.max(budget.loose, desiredTotal - usedDecisions - usedClusters);
135+
128136
const loosePool = useColdStart ? looseMemories.slice(clusterSourceCount) : looseMemories;
129-
const looseLayout = loosePool.slice(0, budget.loose).map((mem, index) => {
137+
const looseLayout = loosePool.slice(0, looseTarget).map((mem, index) => {
130138
const id = `loose-${mem.id}`;
131139
const seed = fnv1a32(id);
132140
const { nx, ny, nz } = fibonacciOnSphere(index, loosePool.length, (seed % 1024) / 1024);

desktop/cortex-control-center/src/brain-v2/index.jsx

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { createHover } from "./Hover.js";
1111
import { createCamera } from "./Camera.js";
1212
import { Hud } from "./Hud.jsx";
1313

14+
const TICKER_MAX = 5;
15+
1416
export function BrainV2({ api = null, cortexBase = "http://127.0.0.1:7437", authToken = "", active = true }) {
1517
const containerRef = useRef(null);
1618
const sceneRef = useRef(null);
@@ -23,16 +25,21 @@ export function BrainV2({ api = null, cortexBase = "http://127.0.0.1:7437", auth
2325
const hoverRef = useRef(null);
2426
const cameraHandleRef = useRef(null);
2527
const slotsAccessor = useRef([]);
26-
const hudRef = useRef(null);
2728
const hoveredSlotRef = useRef(null);
2829
const selectedSlotRef = useRef(null);
30+
const statRefs = useRef({ nodes: null, clusters: null, decisions: null, beams: null });
31+
const lastStatsRef = useRef({ nodes: 0, clusters: 0, decisions: 0, activeBeams: 0 });
32+
const lastStatsAtRef = useRef(0);
33+
const tickerRef = useRef(null);
34+
const tickerEntriesRef = useRef([]);
2935
const [dimensions, setDimensions] = useState({
3036
width: Math.max(window.innerWidth - 260, 400),
3137
height: Math.max(window.innerHeight - 20, 300),
3238
});
3339
const [tiers, setTiers] = useState({ decisions: [], clusters: [], looseMemories: [], coldStart: false });
3440
const [error, setError] = useState(null);
35-
const [stats, setStats] = useState({ nodes: 0, clusters: 0, decisions: 0, activeBeams: 0 });
41+
const [hoverSlot, setHoverSlot] = useState(null);
42+
const [selectedSlot, setSelectedSlot] = useState(null);
3643

3744
useEffect(() => {
3845
if (!active) return undefined;
@@ -79,18 +86,32 @@ export function BrainV2({ api = null, cortexBase = "http://127.0.0.1:7437", auth
7986
slotsRef: slotsAccessor,
8087
onHoverChange: (slot) => {
8188
hoveredSlotRef.current = slot;
82-
hudRef.current?.setHover?.(slot);
89+
setHoverSlot(slot);
8390
},
8491
});
8592
hoverRef.current = hover;
8693

94+
function pushTickerEntry(label) {
95+
const entry = { id: `${performance.now()}-${Math.random()}`, label, ts: performance.now() };
96+
tickerEntriesRef.current = [entry, ...tickerEntriesRef.current].slice(0, TICKER_MAX);
97+
// Update DOM directly (no React re-render).
98+
if (tickerRef.current) {
99+
renderTicker(tickerRef.current, tickerEntriesRef.current);
100+
}
101+
}
102+
87103
const dispatcher = createEventDispatcher({
88104
satellites,
89105
beams,
90106
core,
91107
pulseCoreHalo: () => pulseCoreHalo(core),
92-
onTickerEntry: (label) => hudRef.current?.pushFiringEntry?.(label),
93-
onSpotlight: (slot) => slot && cameraHandle.spotlight(slot),
108+
onTickerEntry: pushTickerEntry,
109+
onSpotlight: (slot) => {
110+
if (slot) {
111+
cameraHandle.pauseAutoRotate();
112+
cameraHandle.spotlight(slot);
113+
}
114+
},
94115
});
95116
dispatcherRef.current = dispatcher;
96117

@@ -123,27 +144,39 @@ export function BrainV2({ api = null, cortexBase = "http://127.0.0.1:7437", auth
123144
};
124145
}
125146

126-
let frame = 0;
127147
const unregister = sceneHandle.registerTick((t, now) => {
128148
tickCore(core, t, now);
129149
satellites.tick(t, now);
130150
beams.tick(now);
131151
cameraHandle.tick(now);
132152
hover.tick();
133-
frame += 1;
134-
if ((frame & 31) === 0) {
153+
154+
// Stats: throttle to once per second AND only update DOM when values change.
155+
if (now - lastStatsAtRef.current >= 1000) {
156+
lastStatsAtRef.current = now;
157+
const slots = slotsAccessor.current || [];
158+
let clusters = 0;
159+
let decisions = 0;
160+
for (const slot of slots) {
161+
if (slot.tier === "cluster") clusters += 1;
162+
else if (slot.tier === "decision") decisions += 1;
163+
}
135164
const next = {
136-
nodes: satellites.getAllIds().length,
137-
clusters: 0,
138-
decisions: 0,
165+
nodes: slots.length,
166+
clusters,
167+
decisions,
139168
activeBeams: beams.activeCount(),
140169
};
141-
const slots = slotsAccessor.current || [];
142-
for (const slot of slots) {
143-
if (slot.tier === "cluster") next.clusters += 1;
144-
else if (slot.tier === "decision") next.decisions += 1;
170+
const prev = lastStatsRef.current;
171+
if (
172+
next.nodes !== prev.nodes
173+
|| next.clusters !== prev.clusters
174+
|| next.decisions !== prev.decisions
175+
|| next.activeBeams !== prev.activeBeams
176+
) {
177+
lastStatsRef.current = next;
178+
writeStats(statRefs.current, next);
145179
}
146-
setStats(next);
147180
}
148181
});
149182

@@ -234,26 +267,29 @@ export function BrainV2({ api = null, cortexBase = "http://127.0.0.1:7437", auth
234267
const slot = hoveredSlotRef.current;
235268
if (!slot) {
236269
satellitesRef.current.setSelected(null);
237-
hudRef.current?.setSelected?.(null);
270+
setSelectedSlot(null);
238271
selectedSlotRef.current = null;
239272
return;
240273
}
241274
if (selectedSlotRef.current?.id === slot.id) {
242275
satellitesRef.current.setSelected(null);
243-
hudRef.current?.setSelected?.(null);
276+
setSelectedSlot(null);
244277
selectedSlotRef.current = null;
245278
return;
246279
}
247280
satellitesRef.current.setSelected(slot.id);
248-
hudRef.current?.setSelected?.(slot);
281+
setSelectedSlot(slot);
249282
selectedSlotRef.current = slot;
250-
cameraHandleRef.current?.spotlight?.(slot);
283+
if (cameraHandleRef.current) {
284+
cameraHandleRef.current.pauseAutoRotate();
285+
cameraHandleRef.current.spotlight(slot);
286+
}
251287
}
252288

253289
function handleContextMenu(e) {
254290
e.preventDefault();
255291
satellitesRef.current?.setSelected(null);
256-
hudRef.current?.setSelected?.(null);
292+
setSelectedSlot(null);
257293
selectedSlotRef.current = null;
258294
}
259295

@@ -278,9 +314,47 @@ export function BrainV2({ api = null, cortexBase = "http://127.0.0.1:7437", auth
278314
{error}
279315
</div>
280316
) : null}
281-
<Hud ref={hudRef} stats={stats} />
317+
<div className="brain-v2-hud-strip">
318+
<span className="brain-v2-hud-stat">
319+
<span className="brain-v2-hud-label">NODES</span>
320+
<span ref={(el) => { statRefs.current.nodes = el; }}>0</span>
321+
</span>
322+
<span className="brain-v2-hud-stat">
323+
<span className="brain-v2-hud-label">CLUSTERS</span>
324+
<span ref={(el) => { statRefs.current.clusters = el; }}>0</span>
325+
</span>
326+
<span className="brain-v2-hud-stat">
327+
<span className="brain-v2-hud-label">DECISIONS</span>
328+
<span ref={(el) => { statRefs.current.decisions = el; }}>0</span>
329+
</span>
330+
<span className="brain-v2-hud-stat">
331+
<span className="brain-v2-hud-label">FIRING</span>
332+
<span ref={(el) => { statRefs.current.beams = el; }}>0</span>
333+
</span>
334+
</div>
335+
<div className="brain-v2-ticker" aria-hidden="true" ref={tickerRef} />
336+
<Hud stats={null} hover={hoverSlot} selected={selectedSlot} firingEntries={[]} />
282337
</div>
283338
);
284339
}
285340

341+
function writeStats(refs, stats) {
342+
if (!refs) return;
343+
if (refs.nodes) refs.nodes.textContent = String(stats.nodes);
344+
if (refs.clusters) refs.clusters.textContent = String(stats.clusters);
345+
if (refs.decisions) refs.decisions.textContent = String(stats.decisions);
346+
if (refs.beams) refs.beams.textContent = String(stats.activeBeams);
347+
}
348+
349+
function renderTicker(host, entries) {
350+
if (!host) return;
351+
while (host.firstChild) host.removeChild(host.firstChild);
352+
for (const entry of entries) {
353+
const div = document.createElement("div");
354+
div.className = "brain-v2-ticker-line";
355+
div.textContent = entry.label;
356+
host.appendChild(div);
357+
}
358+
}
359+
286360
export default BrainV2;

desktop/cortex-control-center/src/brain-visualizer.test.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -221,24 +221,30 @@ describe("Brain v2 interaction (P6)", () => {
221221
expect(cameraSrc).toContain("spotlight(satelliteWorldPos)");
222222
});
223223

224-
it("Hud renders strip + ticker + tooltip + detail panel", () => {
225-
expect(hud).toContain("brain-v2-hud-strip");
226-
expect(hud).toContain("brain-v2-ticker");
224+
it("Hud renders tooltip + detail panel via React props", () => {
227225
expect(hud).toContain("brain-v2-tooltip");
228226
expect(hud).toContain("brain-v2-detail");
229-
expect(hud).toContain("pushFiringEntry");
230-
expect(hud).toContain("setHover");
231-
expect(hud).toContain("setSelected");
232-
expect(hud).toContain("TICKER_MAX = 5");
233-
expect(hud).toContain("TICKER_TTL_MS = 6_000");
227+
expect(hud).toContain("brain-v2-detail-grid");
228+
expect(hud).toContain("brain-v2-detail-row");
229+
expect(hud).toContain("hover && !selected");
230+
expect(hud).toContain("function tierLabel");
231+
});
232+
233+
it("Stats + ticker render via direct DOM refs in BrainV2 (no React reconciliation)", () => {
234+
expect(v2Index).toContain("brain-v2-hud-strip");
235+
expect(v2Index).toContain("brain-v2-ticker");
236+
expect(v2Index).toContain("statRefs");
237+
expect(v2Index).toContain("function writeStats");
238+
expect(v2Index).toContain("function renderTicker");
239+
expect(v2Index).toContain("now - lastStatsAtRef.current >= 1000");
234240
});
235241

236242
it("BrainV2 wires hover + camera spotlight + click-pin + right-click deselect", () => {
237243
expect(v2Index).toContain("createHover");
238244
expect(v2Index).toContain("createCamera");
239245
expect(v2Index).toContain("hoveredSlotRef");
240246
expect(v2Index).toContain("selectedSlotRef");
241-
expect(v2Index).toContain("cameraHandleRef.current?.spotlight");
247+
expect(v2Index).toContain("cameraHandleRef.current.spotlight(slot)");
242248
expect(v2Index).toContain("e.preventDefault()");
243249
expect(v2Index).toContain("onContextMenu={handleContextMenu}");
244250
});

0 commit comments

Comments
 (0)