From 50881bafdabd6973b1e586217eb1b3fd696128ac Mon Sep 17 00:00:00 2001 From: Fadi Shawki Date: Mon, 9 Feb 2026 19:24:22 +0100 Subject: [PATCH 1/2] Playing around with graph rendering --- orbitmines.com/src/App.tsx | 2 + orbitmines.com/src/routes/IDE.tsx | 994 ++++++++++++++++++ .../src/routes/archive/2023.OnOrbits.tsx | 4 +- 3 files changed, 998 insertions(+), 2 deletions(-) create mode 100644 orbitmines.com/src/routes/IDE.tsx diff --git a/orbitmines.com/src/App.tsx b/orbitmines.com/src/App.tsx index abe942dc..125d58d7 100755 --- a/orbitmines.com/src/App.tsx +++ b/orbitmines.com/src/App.tsx @@ -9,6 +9,7 @@ import {Children, ThumbnailPage} from "./lib/post/Post"; import Archive from "./routes/Archive"; import Minimap from './routes/Minimap'; import Almanac from "./routes/Almanac"; +import IDE from "./routes/IDE"; export const Router = () => { @@ -22,6 +23,7 @@ export const Router = () => { } /> } /> + } /> } /> } diff --git a/orbitmines.com/src/routes/IDE.tsx b/orbitmines.com/src/routes/IDE.tsx new file mode 100644 index 00000000..17b0c9b0 --- /dev/null +++ b/orbitmines.com/src/routes/IDE.tsx @@ -0,0 +1,994 @@ +import React, {useCallback, useEffect, useMemo, useReducer, useRef} from 'react'; +import {Canvas, useFrame} from '@react-three/fiber'; +import {Torus} from '@react-three/drei'; +import {ACESFilmicToneMapping, SRGBColorSpace} from 'three'; +import {Vertex, Continuation, Line, torus, add, line} from './archive/2023.OnOrbits'; +import WEBGL from 'three/examples/jsm/capabilities/WebGL'; +import isWebGLAvailable = WEBGL.isWebGLAvailable; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const SCALE = 1.5; +const SPACING_X = 40; +const BRANCH_SPACING = 50; +const CONT_OFFSET = 20; + +// ─── Types ────────────────────────────────────────────────────────────────── + +type NodeId = string; +type ContId = string; +type EdgeId = string; +type Vec3 = [number, number, number]; + +interface GraphNode { + id: NodeId; + col: number; + color: string; + initialCont: ContId; + terminalCont: ContId; +} + +interface Cont { + id: ContId; + nodeId: NodeId; + side: 'initial' | 'terminal'; + connectedEdges: EdgeId[]; +} + +interface Edge { + id: EdgeId; + from: ContId; + to: ContId; + color: string; +} + +interface Cursor { + target: NodeId; + isPrimary: boolean; +} + +type Interaction = + | { mode: 'normal' } + | { mode: 'selecting'; start: Vec3; current: Vec3 } + | { mode: 'dragging'; startPos: Vec3; nodeStartPositions: Record } + | { mode: 'cont_dragging'; contKey: string; startPos: Vec3; startContPos: Vec3 }; + +interface GraphState { + nodes: Record; + continuations: Record; + edges: Record; + cursors: Cursor[]; + selected: Set; + interaction: Interaction; + positionOverrides: Record; + contPositionOverrides: Record; + nextId: number; +} + +// ─── Layout ───────────────────────────────────────────────────────────────── + +// Only follows forward edges (target col > source col) for tree layout +const getChildNodeIds = (nodeId: NodeId, state: GraphState): NodeId[] => { + const node = state.nodes[nodeId]; + if (!node) return []; + const termCont = state.continuations[node.terminalCont]; + if (!termCont) return []; + return termCont.connectedEdges + .map(edgeId => { + const edge = state.edges[edgeId]; + if (!edge) return null; + const targetContId = edge.from === node.terminalCont ? edge.to : edge.from; + const targetCont = state.continuations[targetContId]; + if (!targetCont) return null; + const targetNode = state.nodes[targetCont.nodeId]; + if (!targetNode || targetNode.col <= node.col) return null; + return targetCont.nodeId; + }) + .filter((id): id is NodeId => id !== null && id !== nodeId); +}; + +const getParentNodeId = (nodeId: NodeId, state: GraphState): NodeId | null => { + const node = state.nodes[nodeId]; + if (!node) return null; + const initCont = state.continuations[node.initialCont]; + if (!initCont || initCont.connectedEdges.length === 0) return null; + for (const edgeId of initCont.connectedEdges) { + const edge = state.edges[edgeId]; + if (!edge) continue; + const parentContId = edge.from === node.initialCont ? edge.to : edge.from; + const parentCont = state.continuations[parentContId]; + if (!parentCont) continue; + const parentNode = state.nodes[parentCont.nodeId]; + if (parentNode && parentNode.col < node.col) return parentCont.nodeId; + } + return null; +}; + +const getDescendants = (nodeId: NodeId, state: GraphState): Set => { + const result = new Set(); + const queue: NodeId[] = getChildNodeIds(nodeId, state); + while (queue.length > 0) { + const current = queue.shift()!; + if (result.has(current)) continue; + result.add(current); + queue.push(...getChildNodeIds(current, state)); + } + return result; +}; + +interface SubtreeLayout { + positions: Record; + height: number; +} + +const layoutSubtree = (nodeId: NodeId, state: GraphState, visited: Set): SubtreeLayout => { + if (visited.has(nodeId)) return {positions: {}, height: 0}; + visited.add(nodeId); + + const children = getChildNodeIds(nodeId, state).filter(c => !visited.has(c)); + + if (children.length === 0) { + return {positions: {[nodeId]: 0}, height: 1}; + } + + const childLayouts = children.map(c => ({ + id: c, + layout: layoutSubtree(c, state, new Set(visited)) + })); + + const totalHeight = childLayouts.reduce((sum, cl) => sum + cl.layout.height, 0); + const positions: Record = {}; + + let currentOffset = -totalHeight / 2; + for (const {layout: childLayout} of childLayouts) { + const childCenter = currentOffset + childLayout.height / 2; + for (const [id, relY] of Object.entries(childLayout.positions)) { + positions[id] = childCenter + relY; + } + currentOffset += childLayout.height; + } + + const childYs = children.map(c => positions[c] ?? 0); + const centerY = (Math.min(...childYs) + Math.max(...childYs)) / 2; + for (const id of Object.keys(positions)) { + positions[id] -= centerY; + } + + positions[nodeId] = 0; + return {positions, height: Math.max(totalHeight, 1)}; +}; + +const computeLayout = (state: GraphState): Record => { + const roots = Object.values(state.nodes).filter(n => { + const initCont = state.continuations[n.initialCont]; + return !initCont || initCont.connectedEdges.length === 0; + }); + + const result: Record = {}; + let rootOffset = 0; + + for (const root of roots) { + const {positions, height} = layoutSubtree(root.id, state, new Set()); + for (const [id, relY] of Object.entries(positions)) { + const node = state.nodes[id]; + if (node) { + result[id] = [node.col * SPACING_X, -(relY + rootOffset) * BRANCH_SPACING, 0]; + } + } + rootOffset += height + 0.5; + } + + for (const node of Object.values(state.nodes)) { + if (!result[node.id]) { + result[node.id] = [node.col * SPACING_X, -rootOffset * BRANCH_SPACING, 0]; + rootOffset++; + } + } + + // Apply position overrides from dragging + for (const [id, pos] of Object.entries(state.positionOverrides)) { + result[id] = pos; + } + + return result; +}; + +// ─── Initial state ────────────────────────────────────────────────────────── + +const createInitialState = (): GraphState => { + const nodeId = 'n0'; + const initContId = 'c0'; + const termContId = 'c1'; + return { + nodes: { + [nodeId]: {id: nodeId, col: 0, color: 'orange', initialCont: initContId, terminalCont: termContId} + }, + continuations: { + [initContId]: {id: initContId, nodeId, side: 'initial', connectedEdges: []}, + [termContId]: {id: termContId, nodeId, side: 'terminal', connectedEdges: []} + }, + edges: {}, + cursors: [{target: nodeId, isPrimary: true}], + selected: new Set(), + interaction: {mode: 'normal'}, + positionOverrides: {}, + contPositionOverrides: {}, + nextId: 2, + }; +}; + +// ─── Reducer ──────────────────────────────────────────────────────────────── + +type Action = + | { type: 'ADD_VERTEX_RIGHT' } + | { type: 'ADD_VERTEX_BRANCH' } + | { type: 'MOVE_CURSOR'; direction: 'left' | 'right' | 'up' | 'down' } + | { type: 'SET_CURSOR_AT'; nodeId: NodeId } + | { type: 'ADD_CURSOR_AT'; nodeId: NodeId } + | { type: 'START_SELECTION'; pos: Vec3 } + | { type: 'UPDATE_SELECTION'; pos: Vec3 } + | { type: 'FINISH_SELECTION' } + | { type: 'START_DRAG'; pos: Vec3; clickedNodeId?: NodeId } + | { type: 'UPDATE_DRAG'; pos: Vec3 } + | { type: 'FINISH_DRAG' } + | { type: 'START_CONT_DRAG'; contKey: string; contPos: Vec3; pos: Vec3 } + | { type: 'UPDATE_CONT_DRAG'; pos: Vec3 } + | { type: 'FINISH_CONT_DRAG' }; + +// Deduplicate cursors that ended up at the same node +const dedupCursors = (cursors: Cursor[]): Cursor[] => { + const seen = new Set(); + const result = cursors.filter(c => { + if (seen.has(c.target)) return false; + seen.add(c.target); + return true; + }); + if (result.length > 0 && !result.some(c => c.isPrimary)) { + result[0] = {...result[0], isPrimary: true}; + } + return result; +}; + +const reducer = (state: GraphState, action: Action): GraphState => { + switch (action.type) { + case 'ADD_VERTEX_RIGHT': { + // Process ALL cursors (right-to-left so col shifts don't interfere) + const uniqueTargets = [...new Set(state.cursors.map(c => c.target))]; + const sorted = uniqueTargets + .map(t => ({target: t, col: state.nodes[t]?.col ?? 0})) + .sort((a, b) => b.col - a.col); + + let currentState = state; + let nextId = state.nextId; + const newCursorTargets: NodeId[] = []; + + for (const {target} of sorted) { + const currentNode = currentState.nodes[target]; + if (!currentNode) continue; + + const nodeId = `n${nextId++}`; + const initContId = `c${nextId++}`; + const termContId = `c${nextId++}`; + const edgeId = `e${nextId++}`; + const termCont = currentState.continuations[currentNode.terminalCont]; + const children = getChildNodeIds(currentNode.id, currentState); + + const newNodes = {...currentState.nodes}; + const newConts = {...currentState.continuations}; + const newEdges = {...currentState.edges}; + + // Only shift descendants of the current node, not all nodes with higher col + const descendants = getDescendants(currentNode.id, currentState); + for (const descId of descendants) { + const n = newNodes[descId] ?? currentState.nodes[descId]; + if (n) { + newNodes[n.id] = {...n, col: n.col + 1}; + } + } + + newNodes[nodeId] = { + id: nodeId, col: currentNode.col + 1, color: 'orange', + initialCont: initContId, terminalCont: termContId + }; + + if (children.length > 0) { + const forwardEdgeIds = termCont.connectedEdges.filter(eid => { + const e = currentState.edges[eid]; + if (!e) return false; + const targetContId = e.from === currentNode.terminalCont ? e.to : e.from; + const targetCont = currentState.continuations[targetContId]; + if (!targetCont) return false; + const targetNode = currentState.nodes[targetCont.nodeId]; + return targetNode && targetNode.col > currentNode.col; + }); + + newConts[currentNode.terminalCont] = { + ...termCont, + connectedEdges: termCont.connectedEdges.filter(eid => !forwardEdgeIds.includes(eid)).concat(edgeId) + }; + + const termEdgeIds: EdgeId[] = []; + for (const feid of forwardEdgeIds) { + const oldEdge = currentState.edges[feid]; + if (!oldEdge) continue; + const childContId = oldEdge.from === currentNode.terminalCont ? oldEdge.to : oldEdge.from; + const newEdgeId = `e${nextId++}`; + newEdges[newEdgeId] = {id: newEdgeId, from: termContId, to: childContId, color: 'orange'}; + termEdgeIds.push(newEdgeId); + const childCont = newConts[childContId] || currentState.continuations[childContId]; + newConts[childContId] = { + ...childCont, + connectedEdges: childCont.connectedEdges.filter(eid => eid !== feid).concat(newEdgeId) + }; + delete newEdges[feid]; + } + + newConts[initContId] = {id: initContId, nodeId, side: 'initial', connectedEdges: [edgeId]}; + newConts[termContId] = {id: termContId, nodeId, side: 'terminal', connectedEdges: termEdgeIds}; + } else { + newConts[currentNode.terminalCont] = {...termCont, connectedEdges: [...termCont.connectedEdges, edgeId]}; + newConts[initContId] = {id: initContId, nodeId, side: 'initial', connectedEdges: [edgeId]}; + newConts[termContId] = {id: termContId, nodeId, side: 'terminal', connectedEdges: []}; + } + + newEdges[edgeId] = {id: edgeId, from: currentNode.terminalCont, to: initContId, color: 'orange'}; + + currentState = {...currentState, nodes: newNodes, continuations: newConts, edges: newEdges}; + newCursorTargets.push(nodeId); + } + + return { + ...currentState, + cursors: newCursorTargets.map((t, i) => ({target: t, isPrimary: i === 0})), + selected: new Set(), + interaction: {mode: 'normal'}, + nextId, + }; + } + + case 'ADD_VERTEX_BRANCH': { + // Process ALL cursors + let currentState = state; + let nextId = state.nextId; + const newCursorTargets: NodeId[] = []; + + for (const cursor of state.cursors) { + const currentNode = currentState.nodes[cursor.target]; + if (!currentNode) continue; + + const parentId = getParentNodeId(currentNode.id, currentState); + const branchFromNode = parentId ? currentState.nodes[parentId] : currentNode; + if (!branchFromNode) continue; + + const nodeId = `n${nextId++}`; + const initContId = `c${nextId++}`; + const termContId = `c${nextId++}`; + const edgeId = `e${nextId++}`; + const termCont = currentState.continuations[branchFromNode.terminalCont]; + + currentState = { + ...currentState, + nodes: { + ...currentState.nodes, + [nodeId]: {id: nodeId, col: branchFromNode.col + 1, color: 'orange', initialCont: initContId, terminalCont: termContId} + }, + continuations: { + ...currentState.continuations, + [branchFromNode.terminalCont]: {...termCont, connectedEdges: [...termCont.connectedEdges, edgeId]}, + [initContId]: {id: initContId, nodeId, side: 'initial', connectedEdges: [edgeId]}, + [termContId]: {id: termContId, nodeId, side: 'terminal', connectedEdges: []} + }, + edges: { + ...currentState.edges, + [edgeId]: {id: edgeId, from: branchFromNode.terminalCont, to: initContId, color: 'orange'} + }, + }; + newCursorTargets.push(nodeId); + } + + return { + ...currentState, + cursors: newCursorTargets.map((t, i) => ({target: t, isPrimary: i === 0})), + selected: new Set(), + interaction: {mode: 'normal'}, + nextId, + }; + } + + case 'MOVE_CURSOR': { + // Move ALL cursors, then deduplicate (merge at same node / boundaries) + let newCursors: Cursor[] = []; + + for (const cursor of state.cursors) { + const node = state.nodes[cursor.target]; + if (!node) { newCursors.push(cursor); continue; } + + if (action.direction === 'right') { + const children = getChildNodeIds(cursor.target, state); + if (children.length === 0) { + newCursors.push(cursor); // Stay at boundary + } else { + for (const child of children) { + newCursors.push({target: child, isPrimary: false}); + } + } + } else if (action.direction === 'left') { + const parentId = getParentNodeId(cursor.target, state); + if (parentId) { + newCursors.push({target: parentId, isPrimary: false}); + } else { + newCursors.push(cursor); // Stay at boundary + } + } else { + const parentId = getParentNodeId(cursor.target, state); + if (parentId) { + const siblings = getChildNodeIds(parentId, state); + const idx = siblings.indexOf(cursor.target); + if (idx >= 0) { + const newIdx = action.direction === 'up' + ? Math.max(0, idx - 1) + : Math.min(siblings.length - 1, idx + 1); + newCursors.push({target: siblings[newIdx], isPrimary: false}); + } else { + newCursors.push(cursor); + } + } else { + newCursors.push(cursor); + } + } + } + + newCursors = dedupCursors(newCursors); + if (newCursors.length === 0) return state; + return {...state, cursors: newCursors, selected: new Set()}; + } + + case 'SET_CURSOR_AT': + return {...state, cursors: [{target: action.nodeId, isPrimary: true}], selected: new Set()}; + + case 'ADD_CURSOR_AT': + if (state.cursors.some(c => c.target === action.nodeId)) return state; + return {...state, cursors: [...state.cursors, {target: action.nodeId, isPrimary: false}]}; + + case 'START_SELECTION': + return {...state, interaction: {mode: 'selecting', start: action.pos, current: action.pos}, selected: new Set()}; + + case 'UPDATE_SELECTION': { + if (state.interaction.mode !== 'selecting') return state; + const {start} = state.interaction; + const current = action.pos; + const layout = computeLayout(state); + + const minX = Math.min(start[0], current[0]); + const maxX = Math.max(start[0], current[0]); + const minY = Math.min(start[1], current[1]); + const maxY = Math.max(start[1], current[1]); + + const selected = new Set(); + for (const n of Object.values(state.nodes)) { + const pos = layout[n.id]; + if (pos && pos[0] >= minX && pos[0] <= maxX && pos[1] >= minY && pos[1] <= maxY) { + selected.add(n.id); + } + } + + return {...state, interaction: {...state.interaction, current}, selected}; + } + + case 'FINISH_SELECTION': { + const selectedNodes = [...state.selected]; + const cursors = selectedNodes.map((id, i) => ({target: id, isPrimary: i === 0})); + return { + ...state, + interaction: {mode: 'normal'}, + cursors: cursors.length > 0 ? cursors : state.cursors, + }; + } + + case 'START_DRAG': { + const layout = computeLayout(state); + let nodesToDrag: NodeId[]; + let newState = state; + + if (action.clickedNodeId) { + const isTargeted = state.selected.has(action.clickedNodeId) || state.cursors.some(c => c.target === action.clickedNodeId); + if (!isTargeted) { + newState = {...state, cursors: [{target: action.clickedNodeId, isPrimary: true}], selected: new Set()}; + nodesToDrag = [action.clickedNodeId]; + } else { + nodesToDrag = state.selected.size > 0 ? [...state.selected] : state.cursors.map(c => c.target); + } + } else { + nodesToDrag = state.selected.size > 0 ? [...state.selected] : state.cursors.map(c => c.target); + } + + const nodeStartPositions: Record = {}; + for (const nid of nodesToDrag) { + if (layout[nid]) nodeStartPositions[nid] = layout[nid]; + } + return { + ...newState, + interaction: {mode: 'dragging', startPos: action.pos, nodeStartPositions}, + }; + } + + case 'UPDATE_DRAG': { + if (state.interaction.mode !== 'dragging') return state; + const {startPos, nodeStartPositions} = state.interaction; + const dx = action.pos[0] - startPos[0]; + const dy = action.pos[1] - startPos[1]; + // Merge with existing overrides instead of replacing + const overrides: Record = {...state.positionOverrides}; + for (const [nid, startNodePos] of Object.entries(nodeStartPositions)) { + overrides[nid] = [startNodePos[0] + dx, startNodePos[1] + dy, 0]; + } + return {...state, positionOverrides: overrides}; + } + + case 'FINISH_DRAG': + return {...state, interaction: {mode: 'normal'}}; + + case 'START_CONT_DRAG': + return { + ...state, + interaction: {mode: 'cont_dragging', contKey: action.contKey, startPos: action.pos, startContPos: action.contPos}, + }; + + case 'UPDATE_CONT_DRAG': { + if (state.interaction.mode !== 'cont_dragging') return state; + const {contKey, startPos, startContPos} = state.interaction; + const dx = action.pos[0] - startPos[0]; + const dy = action.pos[1] - startPos[1]; + return { + ...state, + contPositionOverrides: { + ...state.contPositionOverrides, + [contKey]: [startContPos[0] + dx, startContPos[1] + dy, 0], + }, + }; + } + + case 'FINISH_CONT_DRAG': + return {...state, interaction: {mode: 'normal'}}; + + default: + return state; + } +}; + +// ─── Sub-components ───────────────────────────────────────────────────────── + +const CursorIndicator = ({position, isPrimary}: { position: Vec3; isPrimary: boolean }) => { + const ref = useRef(null); + useFrame(({clock}) => { + if (ref.current) { + const s = 1 + Math.sin(clock.getElapsedTime() * 4) * 0.15; + ref.current.scale.set(s, s, s); + } + }); + return ( + + ); +}; + +const SelectionBox = ({start, current}: { start: Vec3; current: Vec3 }) => { + const cx = (start[0] + current[0]) / 2; + const cy = (start[1] + current[1]) / 2; + const w = Math.abs(current[0] - start[0]); + const h = Math.abs(current[1] - start[1]); + if (w < 1 && h < 1) return null; + return ( + null}> + + + + ); +}; + +const EventPlane = ({dispatch, state, layout}: { + dispatch: React.Dispatch; + state: GraphState; + layout: Record; +}) => { + const pendingRef = useRef<{ + type: 'node' | 'cont'; + nodeId?: NodeId; + contKey?: string; + contPos?: Vec3; + startPos: Vec3; + } | null>(null); + const dragStarted = useRef(false); + const isSelecting = useRef(false); + const DRAG_THRESHOLD = 3; + + const toLocal = useCallback((point: { x: number; y: number }): Vec3 => { + return [point.x / SCALE, point.y / SCALE, 0]; + }, []); + + const findNodeAtPos = useCallback((pos: Vec3): GraphNode | undefined => { + const hitRadius = 12; + for (const node of Object.values(state.nodes)) { + const nodePos = layout[node.id]; + if (!nodePos) continue; + const dx = pos[0] - nodePos[0]; + const dy = pos[1] - nodePos[1]; + if (Math.sqrt(dx * dx + dy * dy) < hitRadius) return node; + } + return undefined; + }, [state.nodes, layout]); + + const findContAtPos = useCallback((pos: Vec3): { key: string; pos: Vec3 } | undefined => { + const hitRadius = 10; + let best: { key: string; pos: Vec3 } | undefined; + let bestDist = hitRadius; + + // Check dangling continuations + for (const cont of Object.values(state.continuations)) { + if (cont.connectedEdges.length > 0) continue; + const nodePos = layout[cont.nodeId]; + if (!nodePos) continue; + const offset = cont.side === 'initial' ? -CONT_OFFSET : CONT_OFFSET; + const defaultPos: Vec3 = [nodePos[0] + offset, nodePos[1], 0]; + const contPos = state.contPositionOverrides[cont.id] ?? defaultPos; + const dx = pos[0] - contPos[0]; + const dy = pos[1] - contPos[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < bestDist) { + bestDist = dist; + best = {key: cont.id, pos: contPos}; + } + } + + // Check edge midpoint continuations + for (const edge of Object.values(state.edges)) { + const fromCont = state.continuations[edge.from]; + const toCont = state.continuations[edge.to]; + if (!fromCont || !toCont) continue; + const fromNodePos = layout[fromCont.nodeId]; + const toNodePos = layout[toCont.nodeId]; + if (!fromNodePos || !toNodePos) continue; + const defaultPos: Vec3 = [(fromNodePos[0] + toNodePos[0]) / 2, (fromNodePos[1] + toNodePos[1]) / 2, 0]; + const contPos = state.contPositionOverrides[edge.id] ?? defaultPos; + const dx = pos[0] - contPos[0]; + const dy = pos[1] - contPos[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < bestDist) { + bestDist = dist; + best = {key: edge.id, pos: contPos}; + } + } + + return best; + }, [state.continuations, state.edges, state.contPositionOverrides, layout]); + + return ( + { + e.stopPropagation(); + const localPos = toLocal(e.point); + + if (e.nativeEvent.altKey) { + const node = findNodeAtPos(localPos); + if (node) { + dispatch({type: 'ADD_CURSOR_AT', nodeId: node.id}); + return; + } + } + + const node = findNodeAtPos(localPos); + if (node) { + pendingRef.current = {type: 'node', nodeId: node.id, startPos: localPos}; + return; + } + + const cont = findContAtPos(localPos); + if (cont) { + pendingRef.current = {type: 'cont', contKey: cont.key, contPos: cont.pos, startPos: localPos}; + return; + } + + isSelecting.current = true; + dispatch({type: 'START_SELECTION', pos: localPos}); + }} + onPointerMove={(e) => { + const localPos = toLocal(e.point); + + if (pendingRef.current && !dragStarted.current) { + const dx = localPos[0] - pendingRef.current.startPos[0]; + const dy = localPos[1] - pendingRef.current.startPos[1]; + if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { + dragStarted.current = true; + if (pendingRef.current.type === 'node') { + dispatch({type: 'START_DRAG', pos: pendingRef.current.startPos, clickedNodeId: pendingRef.current.nodeId}); + } else { + dispatch({ + type: 'START_CONT_DRAG', + contKey: pendingRef.current.contKey!, + contPos: pendingRef.current.contPos!, + pos: pendingRef.current.startPos, + }); + } + } + return; + } + + if (dragStarted.current) { + if (state.interaction.mode === 'dragging') { + dispatch({type: 'UPDATE_DRAG', pos: localPos}); + } else if (state.interaction.mode === 'cont_dragging') { + dispatch({type: 'UPDATE_CONT_DRAG', pos: localPos}); + } + return; + } + + if (isSelecting.current && state.interaction.mode === 'selecting') { + dispatch({type: 'UPDATE_SELECTION', pos: localPos}); + } + }} + onPointerUp={() => { + if (dragStarted.current) { + dragStarted.current = false; + if (state.interaction.mode === 'dragging') { + dispatch({type: 'FINISH_DRAG'}); + } else if (state.interaction.mode === 'cont_dragging') { + dispatch({type: 'FINISH_CONT_DRAG'}); + } + pendingRef.current = null; + return; + } + + if (pendingRef.current) { + // Was a click, not a drag + if (pendingRef.current.type === 'node' && pendingRef.current.nodeId) { + dispatch({type: 'SET_CURSOR_AT', nodeId: pendingRef.current.nodeId}); + } + pendingRef.current = null; + return; + } + + if (isSelecting.current) { + isSelecting.current = false; + dispatch({type: 'FINISH_SELECTION'}); + } + }} + > + + + + ); +}; + +// ─── GraphRenderer ────────────────────────────────────────────────────────── + +const GraphRenderer = ({state, layout}: { + state: GraphState; + layout: Record; +}) => { + const scale = SCALE; + + const selectedEdgeIds = useMemo(() => { + const set = new Set(); + for (const edge of Object.values(state.edges)) { + const fromNode = state.continuations[edge.from]?.nodeId; + const toNode = state.continuations[edge.to]?.nodeId; + if ((fromNode && state.selected.has(fromNode)) || (toNode && state.selected.has(toNode))) { + set.add(edge.id); + } + } + return set; + }, [state.edges, state.continuations, state.selected]); + + return ( + <> + {/* Render vertices with dangling continuations */} + {Object.values(state.nodes).map(node => { + const pos = layout[node.id]; + if (!pos) return null; + + const isSelected = state.selected.has(node.id); + const nodeColor = isSelected ? '#55FF55' : node.color; + + const initCont = state.continuations[node.initialCont]; + const termCont = state.continuations[node.terminalCont]; + const initDangling = initCont.connectedEdges.length === 0; + const termDangling = termCont.connectedEdges.length === 0; + + const initColor = isSelected ? '#55FF55' : (initDangling ? '#FF5555' : 'orange'); + const termColor = isSelected ? '#55FF55' : (termDangling ? '#5555FF' : 'orange'); + + return ( + + + {initDangling && (() => { + const contPos = state.contPositionOverrides[node.initialCont] ?? add(pos, [-CONT_OFFSET, 0, 0]); + const dx = contPos[0] - pos[0]; + const dy = contPos[1] - pos[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + const dirX = dist > 0 ? dx / dist : -1; + const dirY = dist > 0 ? dy / dist : 0; + const lineEnd: Vec3 = [contPos[0] - dirX * torus.radius, contPos[1] - dirY * torus.radius, 0]; + return ( + <> + + + + ); + })()} + {termDangling && (() => { + const contPos = state.contPositionOverrides[node.terminalCont] ?? add(pos, [CONT_OFFSET, 0, 0]); + const dx = contPos[0] - pos[0]; + const dy = contPos[1] - pos[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + const dirX = dist > 0 ? dx / dist : 1; + const dirY = dist > 0 ? dy / dist : 0; + const lineEnd: Vec3 = [contPos[0] - dirX * torus.radius, contPos[1] - dirY * torus.radius, 0]; + return ( + <> + + + + ); + })()} + + ); + })} + + {/* Render edges with continuation at midpoint */} + {Object.values(state.edges).map(edge => { + const fromCont = state.continuations[edge.from]; + const toCont = state.continuations[edge.to]; + if (!fromCont || !toCont) return null; + + const fromNodePos = layout[fromCont.nodeId]; + const toNodePos = layout[toCont.nodeId]; + if (!fromNodePos || !toNodePos) return null; + + const isSelected = selectedEdgeIds.has(edge.id); + const edgeColor = isSelected ? '#55FF55' : edge.color; + + // Continuation at midpoint (or overridden position) + const defaultContPos: Vec3 = [ + (fromNodePos[0] + toNodePos[0]) / 2, + (fromNodePos[1] + toNodePos[1]) / 2, + 0, + ]; + const contPos = state.contPositionOverrides[edge.id] ?? defaultContPos; + + // Per-segment direction vectors — lines aim at torus center, clip at torus.radius + const dx1 = contPos[0] - fromNodePos[0]; + const dy1 = contPos[1] - fromNodePos[1]; + const dist1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); + const dir1X = dist1 > 0 ? dx1 / dist1 : 1; + const dir1Y = dist1 > 0 ? dy1 / dist1 : 0; + const lineEnd1: Vec3 = [contPos[0] - dir1X * torus.radius, contPos[1] - dir1Y * torus.radius, 0]; + + const dx2 = contPos[0] - toNodePos[0]; + const dy2 = contPos[1] - toNodePos[1]; + const dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + const dir2X = dist2 > 0 ? dx2 / dist2 : 1; + const dir2Y = dist2 > 0 ? dy2 / dist2 : 0; + const lineStart2: Vec3 = [contPos[0] - dir2X * torus.radius, contPos[1] - dir2Y * torus.radius, 0]; + + return ( + + + + + + ); + })} + + {/* Render cursors */} + {state.cursors.map((cursor, i) => { + const pos = layout[cursor.target]; + if (!pos) return null; + return ; + })} + + {/* Selection box */} + {state.interaction.mode === 'selecting' && ( + + )} + + ); +}; + +// ─── Main component ───────────────────────────────────────────────────────── + +const IDE = () => { + const [state, dispatch] = useReducer(reducer, undefined, createInitialState); + const layout = useMemo( + () => computeLayout(state), + [state.nodes, state.continuations, state.edges, state.positionOverrides] + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (document.activeElement && document.activeElement.tagName === 'INPUT') return; + + switch (e.key) { + case ' ': + e.preventDefault(); + if (state.interaction.mode === 'normal') { + if (e.shiftKey) { + dispatch({type: 'ADD_VERTEX_BRANCH'}); + } else { + dispatch({type: 'ADD_VERTEX_RIGHT'}); + } + } + break; + case 'ArrowLeft': + e.preventDefault(); + dispatch({type: 'MOVE_CURSOR', direction: 'left'}); + break; + case 'ArrowRight': + e.preventDefault(); + dispatch({type: 'MOVE_CURSOR', direction: 'right'}); + break; + case 'ArrowUp': + e.preventDefault(); + dispatch({type: 'MOVE_CURSOR', direction: 'up'}); + break; + case 'ArrowDown': + e.preventDefault(); + dispatch({type: 'MOVE_CURSOR', direction: 'down'}); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [state]); + + if (!isWebGLAvailable()) { + return
WebGL is not available
; + } + + return ( +
+ + + + + + +
+ ); +}; + +export default IDE; diff --git a/orbitmines.com/src/routes/archive/2023.OnOrbits.tsx b/orbitmines.com/src/routes/archive/2023.OnOrbits.tsx index 883ddb89..fe66f971 100644 --- a/orbitmines.com/src/routes/archive/2023.OnOrbits.tsx +++ b/orbitmines.com/src/routes/archive/2023.OnOrbits.tsx @@ -2502,7 +2502,7 @@ export const Line = ({ start, mid, end, scale, color = line.color }: any) => lineWidth={line.width * scale} /> -const line = { width: 2, length: 1, color: "orange", } +export const line = { width: 2, length: 1, color: "orange", } export const Continuation = ( { @@ -4145,7 +4145,7 @@ export const torus = { } export const add = (a: number[], b: number[]): [number, number, number] => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; -const circle = { radius: 3, color: "orange", segments: 30, } +export const circle = { radius: 3, color: "orange", segments: 30, } export const NoWebGL = () => { return
No WebGL
From 04b6c9bd030a7ebecbdcdabf7180e4e68dbced55 Mon Sep 17 00:00:00 2001 From: Fadi Shawki Date: Mon, 9 Feb 2026 19:42:22 +0100 Subject: [PATCH 2/2] Fix flickering --- orbitmines.com/src/routes/IDE.tsx | 71 ++++++++++++++----------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/orbitmines.com/src/routes/IDE.tsx b/orbitmines.com/src/routes/IDE.tsx index 17b0c9b0..74b1ab6a 100644 --- a/orbitmines.com/src/routes/IDE.tsx +++ b/orbitmines.com/src/routes/IDE.tsx @@ -4,7 +4,8 @@ import {Torus} from '@react-three/drei'; import {ACESFilmicToneMapping, SRGBColorSpace} from 'three'; import {Vertex, Continuation, Line, torus, add, line} from './archive/2023.OnOrbits'; import WEBGL from 'three/examples/jsm/capabilities/WebGL'; -import isWebGLAvailable = WEBGL.isWebGLAvailable; + +const HAS_WEBGL = WEBGL.isWebGLAvailable(); // ─── Constants ────────────────────────────────────────────────────────────── @@ -12,6 +13,7 @@ const SCALE = 1.5; const SPACING_X = 40; const BRANCH_SPACING = 50; const CONT_OFFSET = 20; +const VERT_AMP = 3; // Amplify vertical component for torus clip points // ─── Types ────────────────────────────────────────────────────────────────── @@ -556,14 +558,26 @@ const reducer = (state: GraphState, action: Action): GraphState => { } }; +// Compute clip point on torus: amplify vertical component so angled connections +// clip near top/bottom of the torus, horizontal ones stay at the side. +const torusClip = (contPos: Vec3, nodePos: Vec3, fallbackDirX = 1): Vec3 => { + const dx = contPos[0] - nodePos[0]; + const dy = (contPos[1] - nodePos[1]) * VERT_AMP; + const dist = Math.sqrt(dx * dx + dy * dy); + const dirX = dist > 0 ? dx / dist : fallbackDirX; + const dirY = dist > 0 ? dy / dist : 0; + return [contPos[0] - dirX * torus.radius, contPos[1] - dirY * torus.radius, 0]; +}; + // ─── Sub-components ───────────────────────────────────────────────────────── const CursorIndicator = ({position, isPrimary}: { position: Vec3; isPrimary: boolean }) => { const ref = useRef(null); - useFrame(({clock}) => { + useFrame(({clock, invalidate}) => { if (ref.current) { const s = 1 + Math.sin(clock.getElapsedTime() * 4) * 0.15; ref.current.scale.set(s, s, s); + invalidate(); } }); return ( @@ -668,7 +682,7 @@ const EventPlane = ({dispatch, state, layout}: { return ( { e.stopPropagation(); const localPos = toLocal(e.point); @@ -807,31 +821,21 @@ const GraphRenderer = ({state, layout}: { {initDangling && (() => { const contPos = state.contPositionOverrides[node.initialCont] ?? add(pos, [-CONT_OFFSET, 0, 0]); - const dx = contPos[0] - pos[0]; - const dy = contPos[1] - pos[1]; - const dist = Math.sqrt(dx * dx + dy * dy); - const dirX = dist > 0 ? dx / dist : -1; - const dirY = dist > 0 ? dy / dist : 0; - const lineEnd: Vec3 = [contPos[0] - dirX * torus.radius, contPos[1] - dirY * torus.radius, 0]; + const clipPt = torusClip(contPos, pos, -1); return ( <> - + ); })()} {termDangling && (() => { const contPos = state.contPositionOverrides[node.terminalCont] ?? add(pos, [CONT_OFFSET, 0, 0]); - const dx = contPos[0] - pos[0]; - const dy = contPos[1] - pos[1]; - const dist = Math.sqrt(dx * dx + dy * dy); - const dirX = dist > 0 ? dx / dist : 1; - const dirY = dist > 0 ? dy / dist : 0; - const lineEnd: Vec3 = [contPos[0] - dirX * torus.radius, contPos[1] - dirY * torus.radius, 0]; + const clipPt = torusClip(contPos, pos, 1); return ( <> - + ); })()} @@ -860,26 +864,15 @@ const GraphRenderer = ({state, layout}: { ]; const contPos = state.contPositionOverrides[edge.id] ?? defaultContPos; - // Per-segment direction vectors — lines aim at torus center, clip at torus.radius - const dx1 = contPos[0] - fromNodePos[0]; - const dy1 = contPos[1] - fromNodePos[1]; - const dist1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); - const dir1X = dist1 > 0 ? dx1 / dist1 : 1; - const dir1Y = dist1 > 0 ? dy1 / dist1 : 0; - const lineEnd1: Vec3 = [contPos[0] - dir1X * torus.radius, contPos[1] - dir1Y * torus.radius, 0]; - - const dx2 = contPos[0] - toNodePos[0]; - const dy2 = contPos[1] - toNodePos[1]; - const dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); - const dir2X = dist2 > 0 ? dx2 / dist2 : 1; - const dir2Y = dist2 > 0 ? dy2 / dist2 : 0; - const lineStart2: Vec3 = [contPos[0] - dir2X * torus.radius, contPos[1] - dir2Y * torus.radius, 0]; + // Clip points on torus — amplified vertical for smooth curve entry + const clipPt1 = torusClip(contPos, fromNodePos); + const clipPt2 = torusClip(contPos, toNodePos); return ( - + - + ); })} @@ -908,6 +901,9 @@ const IDE = () => { [state.nodes, state.continuations, state.edges, state.positionOverrides] ); + const stateRef = useRef(state); + stateRef.current = state; + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.activeElement && document.activeElement.tagName === 'INPUT') return; @@ -915,7 +911,7 @@ const IDE = () => { switch (e.key) { case ' ': e.preventDefault(); - if (state.interaction.mode === 'normal') { + if (stateRef.current.interaction.mode === 'normal') { if (e.shiftKey) { dispatch({type: 'ADD_VERTEX_BRANCH'}); } else { @@ -944,9 +940,9 @@ const IDE = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [state]); + }, []); - if (!isWebGLAvailable()) { + if (!HAS_WEBGL) { return
WebGL is not available
; } @@ -967,7 +963,6 @@ const IDE = () => { powerPreference: 'high-performance', outputColorSpace: SRGBColorSpace, toneMapping: ACESFilmicToneMapping, - preserveDrawingBuffer: true, }} camera={{ fov: 70, @@ -978,7 +973,7 @@ const IDE = () => { orthographic dpr={[1, 2]} linear={false} - frameloop="always" + frameloop="demand" resize={{scroll: true, debounce: {scroll: 50, resize: 0}}} eventPrefix="offset" >