diff --git a/frontend/src/ContextMenu.tsx b/frontend/src/ContextMenu.tsx index fa4c50d..5670ffc 100644 --- a/frontend/src/ContextMenu.tsx +++ b/frontend/src/ContextMenu.tsx @@ -3,6 +3,7 @@ import { socketSpecAcceptsType } from './constants'; import { outputTypeCanConnectToTarget } from './connectionUtils'; import { compareMenuNodes, compareMenuCategories } from './canvasEvents'; import { useFavorites } from './favorites'; +import { recordUsage, pickWeightedRandom } from './nodeUsage'; const FAVORITES_CATEGORY = 'favorites'; @@ -213,6 +214,29 @@ export default function ContextMenu({ setOpenCat(cat); }, []); + const allNodeEntries = useMemo(() => { + const map = new Map(); + for (const category of categories) { + for (const item of category.items) { + if (!map.has(item.className)) map.set(item.className, item.def); + } + } + return map; + }, [categories]); + + const handleAdd = useCallback((className: string, def: any) => { + recordUsage(className); + onAdd(className, def); + }, [onAdd]); + + const handleRandomNode = useCallback(() => { + const classNames = [...allNodeEntries.keys()]; + const pick = pickWeightedRandom(classNames); + if (!pick) return; + const def = allNodeEntries.get(pick); + if (def) { handleAdd(pick, def); onClose(); } + }, [allNodeEntries, handleAdd, onClose]); + if (categories.length === 0) { return (
e.stopPropagation()}> @@ -256,7 +280,7 @@ export default function ContextMenu({ className="context-item" onClick={() => { onCreateGroup(); onClose(); }} > - create group + Create Group
)} @@ -270,7 +294,7 @@ export default function ContextMenu({ key={className} ref={idx === selectedIndex ? selectedItemRef : null} className={`context-item${idx === selectedIndex ? ' context-item--selected' : ''}`} - onClick={() => { onAdd(className, def); onClose(); }} + onClick={() => { handleAdd(className, def); onClose(); }} onMouseEnter={() => setSelectedIndex(idx)} > {def.display_name || className} @@ -288,11 +312,18 @@ export default function ContextMenu({ onMouseEnter={() => handleCatEnter(cat)} > - {cat === FAVORITES_CATEGORY ? '♥ favorites' : cat} + {cat === FAVORITES_CATEGORY ? 'Favorites' : cat} ))} +
setOpenCat(null)} + > + surprise me +
)} @@ -314,7 +345,7 @@ export default function ContextMenu({
{ onAdd(className, def); onClose(); }} + onClick={() => { handleAdd(className, def); onClose(); }} > {def.display_name || className}
diff --git a/frontend/src/CustomNode.tsx b/frontend/src/CustomNode.tsx index 0f53b1d..2f39b4b 100644 --- a/frontend/src/CustomNode.tsx +++ b/frontend/src/CustomNode.tsx @@ -1426,15 +1426,17 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { {/* Interactive 3D surface view */} {!!data.meshData && ( - Loading 3D...}> - - + + Loading 3D...}> + + + )} diff --git a/frontend/src/SurfaceView.tsx b/frontend/src/SurfaceView.tsx index 0114f2b..80d5bf1 100644 --- a/frontend/src/SurfaceView.tsx +++ b/frontend/src/SurfaceView.tsx @@ -133,6 +133,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal const pointerEnteredAtRef = useRef(0); const lastWheelAtRef = useRef(0); const gestureStartedInsideRef = useRef(false); + const scheduleViewportSyncRef = useRef<(delay?: number, scheduleRun?: boolean) => void>(() => {}); + const updateDiagnosticsRef = useRef<(patch: Partial) => void>(() => {}); const [diagnostics, setDiagnostics] = useState({ status: meshData ? 'initializing' : 'waiting for mesh', webgl: 'pending', @@ -239,6 +241,9 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal scheduleViewportSync(0, true); }, [applyCameraState, scheduleViewportSync]); + scheduleViewportSyncRef.current = scheduleViewportSync; + updateDiagnosticsRef.current = updateDiagnostics; + // Initialize Three.js scene once useEffect(() => { const container = containerRef.current; @@ -256,8 +261,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal renderer.setPixelRatio(window.devicePixelRatio); renderer.setClearColor(0x0f172a); container.appendChild(renderer.domElement); - updateDiagnostics({ - status: meshData ? 'renderer ready' : 'waiting for mesh', + updateDiagnosticsRef.current({ + status: 'renderer ready', webgl: `${renderer.capabilities.isWebGL2 ? 'webgl2' : 'webgl1'} / ${renderer.capabilities.precision}`, canvas: `${renderer.domElement.width}x${renderer.domElement.height} px`, render: `calls ${renderer.info.render.calls} tris ${renderer.info.render.triangles} geo ${renderer.info.memory.geometries}`, @@ -266,13 +271,13 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal const handleContextLost = (event: Event) => { event.preventDefault(); - updateDiagnostics({ + updateDiagnosticsRef.current({ status: 'webgl context lost', error: 'WebGL context lost', }); }; const handleContextRestored = () => { - updateDiagnostics({ + updateDiagnosticsRef.current({ status: 'webgl context restored', error: '', }); @@ -303,7 +308,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal TWO: THREE.TOUCH.DOLLY_ROTATE, }; renderer.domElement.style.touchAction = 'none'; - const handleControlsEnd = () => scheduleViewportSync(120, true); + const handleControlsEnd = () => scheduleViewportSyncRef.current(120, true); controls.addEventListener('end', handleControlsEnd); // Lighting @@ -341,7 +346,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal r.setSize(w, w); c.aspect = 1; c.updateProjectionMatrix(); - updateDiagnostics({ + updateDiagnosticsRef.current({ canvas: `${r.domElement.width}x${r.domElement.height} px`, }); }); @@ -361,7 +366,8 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal } threeRef.current = null; }; - }, [scheduleViewportSync, updateDiagnostics]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [applyCameraState]); useEffect(() => { if (meshData) { diff --git a/frontend/src/nodeUsage.ts b/frontend/src/nodeUsage.ts new file mode 100644 index 0000000..2f41162 --- /dev/null +++ b/frontend/src/nodeUsage.ts @@ -0,0 +1,46 @@ +const STORAGE_KEY = 'tono_node_usage_counts'; + +let counts: Record = loadFromStorage(); + +function loadFromStorage(): Record { + if (typeof localStorage === 'undefined') return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return {}; + return parsed; + } catch { + return {}; + } +} + +function persist(): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(counts)); + } catch { + // ignore + } +} + +export function recordUsage(className: string): void { + counts = { ...counts, [className]: (counts[className] || 0) + 1 }; + persist(); +} + +export function getUsageCount(className: string): number { + return counts[className] || 0; +} + +export function pickWeightedRandom(classNames: string[]): string | null { + if (classNames.length === 0) return null; + const weights = classNames.map((cn) => 1 / (1 + (counts[cn] || 0))); + const total = weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (let i = 0; i < classNames.length; i++) { + r -= weights[i]; + if (r <= 0) return classNames[i]; + } + return classNames[classNames.length - 1]; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index cec2448..eafd791 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2515,6 +2515,11 @@ html, body, #root { .ctx-cat-favorites .ctx-cat-label { text-transform: none; } +.ctx-random-node { + border-top: 1px solid var(--border-strong); + color: var(--text-secondary); + font-style: italic; +} /* ── Submenu panel (separate fixed-position sibling) ── */ .ctx-submenu {