fix: node graph zoom, text selection, image previews, resolution

1. Zoom-to-cursor: compute new pan/zoom synchronously from refs so rapid
   wheel events are coherent (was calling setPan inside setZoom updater,
   causing stale-state drift on fast scrolling)

2. Stable global handlers: move pan/zoom/graph/onChange into refs so the
   mousemove/mouseup/keydown effect has no deps and never re-registers

3. Source/Output images: accept sourceImageB64 + outputImageB64 props and
   show them as thumbnails on the Source and Output nodes

4. Text selection: add userSelect:none to canvas container; call
   e.preventDefault() on all drag-start mousedowns; draggable={false} on
   preview images

5. Preview resolution: bump map_to_b64_small max dim 256→512px so node
   thumbnails stay crisp at moderate zoom levels; drop pixelated rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 20:42:24 -07:00
parent a6ae70558b
commit c5a2c51e9f
3 changed files with 193 additions and 208 deletions

View File

@@ -416,6 +416,8 @@ export default function App() {
scheduleProcess(activePass)
}}
nodePreviews={passes[activePass].nodePreviews}
sourceImageB64={image?.preview_b64 ?? null}
outputImageB64={passes[activePass]?.vizB64 ?? null}
/>
) : (
<Viewport

View File

@@ -3,10 +3,10 @@ import Slider from './Slider.jsx'
import { KERNELS, BLEND_MODES, defaultKernelProps, newNodeId } from '../store.js'
// ── Layout constants ───────────────────────────────────────────────────────────
const NODE_W = 220
const PORT_R = 6
const PORT_TOP = 18 // y offset of first port from node top (center of header)
const PORT_STRIDE = 26 // vertical gap between combine input ports
const NODE_W = 220
const PORT_R = 6
const PORT_TOP = 18 // y from node top to first/only port centre
const PORT_STRIDE = 26 // vertical stride between combine input ports
const KERNEL_PARAMS = {
Luminance: ['blur_radius'],
@@ -27,59 +27,59 @@ const PARAM_META = {
xdog_phi: { label: 'φ', min: 1, max: 100, step: 1 },
}
// ── Port position helpers (world coords) ───────────────────────────────────────
function outPort(node) {
return { x: node.x + NODE_W, y: node.y + PORT_TOP }
}
function inPort(node, portIdx) {
return { x: node.x, y: node.y + PORT_TOP + portIdx * PORT_STRIDE }
function inPort(node, idx) {
return { x: node.x, y: node.y + PORT_TOP + idx * PORT_STRIDE }
}
function edgePath(from, to) {
function bezier(from, to) {
const dx = Math.max(Math.abs(to.x - from.x) * 0.5, 60)
return `M ${from.x} ${from.y} C ${from.x + dx} ${from.y}, ${to.x - dx} ${to.y}, ${to.x} ${to.y}`
}
// ── Main component ─────────────────────────────────────────────────────────────
export default function NodeGraph({ graph, onChange, nodePreviews }) {
// ── Component ──────────────────────────────────────────────────────────────────
export default function NodeGraph({ graph, onChange, nodePreviews, sourceImageB64, outputImageB64 }) {
const canvasRef = useRef(null)
const [pan, setPan] = useState({ x: 40, y: 40 })
const [pan, setPan] = useState({ x: 40, y: 40 })
const [zoom, setZoom] = useState(1)
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
// pan drag
const panRef = useRef(null)
// Refs mirror current state so event handlers don't need to be recreated
const panRef = useRef({ x: 40, y: 40 })
const zoomRef = useRef(1)
const graphRef = useRef(graph)
const onChangeRef = useRef(onChange)
panRef.current = pan
zoomRef.current = zoom
graphRef.current = graph
onChangeRef.current = onChange
// node drag
const nodeDragRef = useRef(null)
// Drag state stored in refs so handlers remain stable
const panDragRef = useRef(null) // { startX, startY } — canvas-space origin
const nodeDragRef = useRef(null) // { nodeId, startNodeX/Y, startClientX/Y }
const wireRef = useRef(null) // same shape as wire state
// wire in progress
const [wire, setWire] = useState(null) // { fromId, fromX, fromY, mouseX, mouseY }
const wireRef = useRef(null)
// ── Coordinate conversion ──────────────────────────────────────────────────
const toWorld = useCallback((clientX, clientY) => {
const r = canvasRef.current.getBoundingClientRect()
return {
x: (clientX - r.left - pan.x) / zoom,
y: (clientY - r.top - pan.y) / zoom,
}
}, [pan, zoom])
// ── Wheel zoom ─────────────────────────────────────────────────────────────
// ── Wheel zoom — zoom to cursor, computed from refs to handle rapid scroll ──
const onWheel = useCallback(e => {
e.preventDefault()
const r = canvasRef.current.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
const r = canvasRef.current.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
setZoom(z => {
const nz = Math.min(Math.max(z * factor, 0.2), 4)
setPan(p => ({
x: cx - (cx - p.x) * (nz / z),
y: cy - (cy - p.y) * (nz / z),
}))
return nz
})
}, [])
const z = zoomRef.current
const p = panRef.current
const nz = Math.min(Math.max(z * factor, 0.15), 5)
const np = {
x: cx - (cx - p.x) * (nz / z),
y: cy - (cy - p.y) * (nz / z),
}
// Update refs immediately so back-to-back wheel events are coherent
zoomRef.current = nz
panRef.current = np
setZoom(nz)
setPan(np)
}, []) // stable — reads from refs
useEffect(() => {
const el = canvasRef.current
@@ -87,46 +87,39 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
return () => el.removeEventListener('wheel', onWheel)
}, [onWheel])
// ── Canvas mouse handlers (pan + wire cancel) ──────────────────────────────
function onCanvasMouseDown(e) {
if (e.target !== canvasRef.current && !e.target.dataset.canvas) return
if (e.button !== 0) return
panRef.current = { startX: e.clientX - pan.x, startY: e.clientY - pan.y }
}
// ── Global mouse move / up ─────────────────────────────────────────────────
// ── Global mouse move / up / keydown — stable, reads from refs ─────────────
useEffect(() => {
function onMove(e) {
if (panRef.current) {
setPan({ x: e.clientX - panRef.current.startX, y: e.clientY - panRef.current.startY })
if (panDragRef.current) {
const np = { x: e.clientX - panDragRef.current.startX, y: e.clientY - panDragRef.current.startY }
panRef.current = np
setPan(np)
}
if (nodeDragRef.current) {
const { nodeId, startNodeX, startNodeY, startX, startY } = nodeDragRef.current
const wx = startNodeX + (e.clientX - startX) / zoom
const wy = startNodeY + (e.clientY - startY) / zoom
onChange({ ...graph, nodes: graph.nodes.map(n => n.id === nodeId ? { ...n, x: wx, y: wy } : n) })
const { nodeId, startNodeX, startNodeY, startClientX, startClientY } = nodeDragRef.current
const z = zoomRef.current
const nx = startNodeX + (e.clientX - startClientX) / z
const ny = startNodeY + (e.clientY - startClientY) / z
const g = graphRef.current
onChangeRef.current({ ...g, nodes: g.nodes.map(n => n.id === nodeId ? { ...n, x: nx, y: ny } : n) })
}
if (wireRef.current) {
const r = canvasRef.current.getBoundingClientRect()
const mx = (e.clientX - r.left - pan.x) / zoom
const my = (e.clientY - r.top - pan.y) / zoom
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
const r = canvasRef.current.getBoundingClientRect()
const p = panRef.current
const z = zoomRef.current
const mx = (e.clientX - r.left - p.x) / z
const my = (e.clientY - r.top - p.y) / z
wireRef.current = { ...wireRef.current, mouseX: mx, mouseY: my }
setWire(w => w ? { ...w, mouseX: mx, mouseY: my } : w)
}
}
function onUp(e) {
panRef.current = null
function onUp() {
panDragRef.current = null
nodeDragRef.current = null
if (e.key === undefined && wireRef.current) {
setWire(null)
wireRef.current = null
}
if (wireRef.current) { wireRef.current = null; setWire(null) }
}
function onKey(e) {
if (e.key === 'Escape') {
setWire(null)
wireRef.current = null
}
if (e.key === 'Escape') { wireRef.current = null; setWire(null) }
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
@@ -136,12 +129,21 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
window.removeEventListener('mouseup', onUp)
window.removeEventListener('keydown', onKey)
}
}, [graph, onChange, pan, zoom])
}, []) // stable — all reads go through refs
// ── Wire interactions ──────────────────────────────────────────────────────
// ── Canvas background mousedown → pan ─────────────────────────────────────
function onCanvasMouseDown(e) {
if (e.target !== canvasRef.current && !e.target.dataset.canvas) return
if (e.button !== 0) return
e.preventDefault()
panDragRef.current = { startX: e.clientX - panRef.current.x, startY: e.clientY - panRef.current.y }
}
// ── Wire ───────────────────────────────────────────────────────────────────
function startWire(e, fromId) {
e.stopPropagation()
const node = graph.nodes.find(n => n.id === fromId)
e.preventDefault()
const node = graphRef.current.nodes.find(n => n.id === fromId)
const p = outPort(node)
const state = { fromId, fromX: p.x, fromY: p.y, mouseX: p.x, mouseY: p.y }
wireRef.current = state
@@ -152,142 +154,140 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
e.stopPropagation()
if (!wireRef.current) return
const { fromId } = wireRef.current
// Prevent self-loop and duplicate edges
if (fromId === toId) { setWire(null); wireRef.current = null; return }
const exists = graph.edges.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)
if (!exists) {
// Remove any existing edge to this specific port
const filtered = graph.edges.filter(ed => !(ed.to === toId && ed.port === port))
onChange({ ...graph, edges: [...filtered, { from: fromId, to: toId, port }] })
if (fromId !== toId) {
const g = graphRef.current
const filtered = g.edges.filter(ed => !(ed.to === toId && ed.port === port))
if (!filtered.some(ed => ed.from === fromId && ed.to === toId && ed.port === port)) {
onChangeRef.current({ ...g, edges: [...filtered, { from: fromId, to: toId, port }] })
}
}
setWire(null)
wireRef.current = null
setWire(null)
}
function removeEdge(idx) {
onChange({ ...graph, edges: graph.edges.filter((_, i) => i !== idx) })
const g = graphRef.current
onChangeRef.current({ ...g, edges: g.edges.filter((_, i) => i !== idx) })
}
// ── Node mutations ─────────────────────────────────────────────────────────
function updateNode(id, patch) {
onChange({ ...graph, nodes: graph.nodes.map(n => n.id === id ? { ...n, ...patch } : n) })
const g = graphRef.current
onChangeRef.current({ ...g, nodes: g.nodes.map(n => n.id === id ? { ...n, ...patch } : n) })
}
function deleteNode(id) {
onChange({
nodes: graph.nodes.filter(n => n.id !== id),
edges: graph.edges.filter(e => e.from !== id && e.to !== id),
const g = graphRef.current
onChangeRef.current({
nodes: g.nodes.filter(n => n.id !== id),
edges: g.edges.filter(e => e.from !== id && e.to !== id),
})
}
function addNode(kind) {
const r = canvasRef.current.getBoundingClientRect()
const x = (r.width / 2 - pan.x) / zoom - NODE_W / 2
const y = (r.height / 2 - pan.y) / zoom - 60
const p = panRef.current; const z = zoomRef.current
const x = (r.width / 2 - p.x) / z - NODE_W / 2
const y = (r.height / 2 - p.y) / z - 60
const id = newNodeId(kind)
const base = { id, kind, x, y }
const node = kind === 'Kernel'
? { ...base, ...defaultKernelProps() }
: { ...base, blendMode: 'Average', inputCount: 2 }
onChange({ ...graph, nodes: [...graph.nodes, node] })
? { id, kind, x, y, ...defaultKernelProps() }
: { id, kind, x, y, blendMode: 'Average', inputCount: 2 }
const g = graphRef.current
onChangeRef.current({ ...g, nodes: [...g.nodes, node] })
}
function addCombinePort(id) {
const n = graph.nodes.find(n => n.id === id)
const n = graphRef.current.nodes.find(n => n.id === id)
updateNode(id, { inputCount: (n.inputCount ?? 2) + 1 })
}
function removeCombinePort(id) {
const n = graph.nodes.find(n => n.id === id)
const g = graphRef.current
const n = g.nodes.find(n => n.id === id)
const cnt = n.inputCount ?? 2
if (cnt <= 2) return
const lastPort = cnt - 1
onChange({
...graph,
nodes: graph.nodes.map(nd => nd.id === id ? { ...nd, inputCount: cnt - 1 } : nd),
edges: graph.edges.filter(e => !(e.to === id && e.port === lastPort)),
onChangeRef.current({
...g,
nodes: g.nodes.map(nd => nd.id === id ? { ...nd, inputCount: cnt - 1 } : nd),
edges: g.edges.filter(e => !(e.to === id && e.port === cnt - 1)),
})
}
// ── Node header drag start ─────────────────────────────────────────────────
// ── Node header drag ───────────────────────────────────────────────────────
function startNodeDrag(e, nodeId) {
e.stopPropagation()
if (e.button !== 0) return
const node = graph.nodes.find(n => n.id === nodeId)
nodeDragRef.current = {
nodeId, startNodeX: node.x, startNodeY: node.y,
startX: e.clientX, startY: e.clientY,
}
e.stopPropagation()
e.preventDefault()
const node = graphRef.current.nodes.find(n => n.id === nodeId)
nodeDragRef.current = { nodeId, startNodeX: node.x, startNodeY: node.y, startClientX: e.clientX, startClientY: e.clientY }
}
// ── Node rendering ─────────────────────────────────────────────────────────
function renderNode(node) {
const isFixed = node.kind === 'Source' || node.kind === 'Output'
const preview = nodePreviews?.[node.id]
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2) : node.kind === 'Kernel' || node.kind === 'Output' ? 1 : 0
const inputCnt = node.kind === 'Combine' ? (node.inputCount ?? 2)
: (node.kind === 'Kernel' || node.kind === 'Output') ? 1 : 0
const hasOut = node.kind !== 'Output'
// Preview image: explicit source/output images, or per-node detection map
const preview = node.kind === 'Source' ? sourceImageB64
: node.kind === 'Output' ? outputImageB64
: nodePreviews?.[node.id]
const accentColor = node.kind === 'Source' ? '#7c3aed'
: node.kind === 'Output' ? '#b45309'
: '#374151'
const headerBg = node.kind === 'Source' ? '#2e1065'
: node.kind === 'Output' ? '#1c1003'
: '#1e293b'
return (
<div key={node.id}
style={{
position: 'absolute', left: node.x, top: node.y,
width: NODE_W, userSelect: 'none',
}}
>
<div key={node.id} style={{ position: 'absolute', left: node.x, top: node.y, width: NODE_W }}>
{/* Input ports */}
{Array.from({ length: inputCnt }, (_, i) => (
<div key={i}
onMouseUp={e => endWire(e, node.id, i)}
style={{
position: 'absolute',
left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R,
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%',
background: wire ? '#14b8a6' : '#374151',
border: '2px solid #14b8a6', cursor: 'crosshair', zIndex: 10,
boxShadow: wire ? '0 0 6px #14b8a6' : 'none',
transition: 'background 0.15s, box-shadow 0.15s',
position: 'absolute', left: -PORT_R, top: PORT_TOP + i * PORT_STRIDE - PORT_R,
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
background: wire ? '#14b8a6' : '#1e3a3a',
border: `2px solid #14b8a6`, cursor: 'crosshair',
boxShadow: wire ? '0 0 8px #14b8a6aa' : 'none',
transition: 'background 0.12s, box-shadow 0.12s',
}}
/>
))}
{/* Output port */}
{hasOut && (
<div
onMouseDown={e => startWire(e, node.id)}
<div onMouseDown={e => startWire(e, node.id)}
style={{
position: 'absolute',
right: -PORT_R, top: PORT_TOP - PORT_R,
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%',
background: '#6366f1', border: '2px solid #818cf8',
cursor: 'crosshair', zIndex: 10,
position: 'absolute', right: -PORT_R, top: PORT_TOP - PORT_R,
width: PORT_R * 2, height: PORT_R * 2, borderRadius: '50%', zIndex: 10,
background: '#4f46e5', border: '2px solid #818cf8', cursor: 'crosshair',
}}
/>
)}
{/* Node body */}
<div style={{
background: '#1a1a2e',
border: `1px solid ${node.kind === 'Source' ? '#7c3aed' : node.kind === 'Output' ? '#b45309' : '#374151'}`,
borderRadius: 8, overflow: 'hidden',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
background: '#13131f', border: `1px solid ${accentColor}`,
borderRadius: 8, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)',
}}>
{/* Header */}
<div
onMouseDown={e => startNodeDrag(e, node.id)}
{/* Header / drag handle */}
<div onMouseDown={e => startNodeDrag(e, node.id)}
style={{
padding: '4px 8px', cursor: 'move',
background: node.kind === 'Source' ? '#2e1065' : node.kind === 'Output' ? '#1c1003' : '#1e293b',
display: 'flex', alignItems: 'center', gap: 6, minHeight: 32,
padding: '4px 8px', minHeight: 32, cursor: 'move',
background: headerBg, display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em' }}>
{node.kind === 'Source' ? 'Source'
: node.kind === 'Output' ? 'Output'
: node.kind === 'Kernel' ? node.kernel ?? 'Kernel'
: 'Combine'}
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e2e8f0', letterSpacing: '0.05em', pointerEvents: 'none' }}>
{node.kind === 'Source' ? 'Source'
: node.kind === 'Output' ? 'Output'
: node.kind === 'Kernel' ? (node.kernel ?? 'Kernel')
: 'Combine'}
</span>
{!isFixed && (
<button
onClick={() => deleteNode(node.id)}
onMouseDown={e => e.stopPropagation()}
style={{ color: '#6b7280', fontSize: 11, cursor: 'pointer', background: 'none', border: 'none', lineHeight: 1 }}
<button onClick={() => deleteNode(node.id)} onMouseDown={e => e.stopPropagation()}
style={{ color: '#6b7280', fontSize: 11, cursor: 'pointer', background: 'none', border: 'none', lineHeight: 1, padding: 0 }}
></button>
)}
</div>
@@ -295,14 +295,10 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
{/* Body */}
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{/* Kernel controls */}
{node.kind === 'Kernel' && (<>
{/* Kernel selector */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{KERNELS.map(k => (
<button key={k}
onMouseDown={e => e.stopPropagation()}
onClick={() => updateNode(node.id, { kernel: k })}
<button key={k} onMouseDown={e => e.stopPropagation()} onClick={() => updateNode(node.id, { kernel: k })}
style={{
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
background: node.kernel === k ? '#4f46e5' : '#1e293b',
@@ -318,22 +314,19 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
return <Slider key={p} label={m.label} value={node[p] ?? 0} min={m.min} max={m.max} step={m.step}
onChange={v => updateNode(node.id, { [p]: v })} />
})}
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 10, color: '#94a3b8' }}>
<label onMouseDown={e => e.stopPropagation()}
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 10, color: '#94a3b8' }}>
<input type="checkbox" checked={node.invert ?? false}
onChange={e => updateNode(node.id, { invert: e.target.checked })}
style={{ accentColor: '#6366f1' }}
/>
style={{ accentColor: '#6366f1' }} />
Invert
</label>
</>)}
{/* Combine controls */}
{node.kind === 'Combine' && (<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{BLEND_MODES.map(m => (
<button key={m}
onMouseDown={e => e.stopPropagation()}
onClick={() => updateNode(node.id, { blendMode: m })}
<button key={m} onMouseDown={e => e.stopPropagation()} onClick={() => updateNode(node.id, { blendMode: m })}
style={{
padding: '1px 5px', borderRadius: 3, fontSize: 10, cursor: 'pointer', border: 'none',
background: node.blendMode === m ? '#0f766e' : '#1e293b',
@@ -345,16 +338,16 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 10, color: '#6b7280' }}>Inputs: {node.inputCount ?? 2}</span>
<button onMouseDown={e => e.stopPropagation()} onClick={() => addCombinePort(node.id)}
style={{ fontSize: 11, color: '#6366f1', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px' }}>+</button>
style={{ fontSize: 12, color: '#6366f1', background: 'none', border: 'none', cursor: 'pointer', padding: '0 3px' }}>+</button>
<button onMouseDown={e => e.stopPropagation()} onClick={() => removeCombinePort(node.id)}
style={{ fontSize: 11, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px' }}></button>
style={{ fontSize: 12, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: '0 3px' }}></button>
</div>
</>)}
{/* Preview thumbnail */}
{preview && (
<img src={`data:image/jpeg;base64,${preview}`} alt=""
style={{ width: '100%', borderRadius: 4, marginTop: 2, display: 'block', imageRendering: 'pixelated' }}
<img src={`data:image/jpeg;base64,${preview}`} alt="" draggable={false}
style={{ width: '100%', borderRadius: 4, marginTop: 2, display: 'block' }}
/>
)}
</div>
@@ -365,72 +358,62 @@ export default function NodeGraph({ graph, onChange, nodePreviews }) {
// ── SVG edges ──────────────────────────────────────────────────────────────
const nodeById = Object.fromEntries(graph.nodes.map(n => [n.id, n]))
const svgEdges = graph.edges.map((edge, idx) => {
const fn_ = nodeById[edge.from]
const tn = nodeById[edge.to]
if (!fn_ || !tn) return null
const from = outPort(fn_)
const to = inPort(tn, edge.port)
return (
<path key={idx}
d={edgePath(from, to)}
stroke="#6366f1" strokeWidth={1.5} fill="none" opacity={0.7}
strokeLinecap="round"
style={{ cursor: 'pointer' }}
onClick={() => removeEdge(idx)}
/>
)
})
const wireEdge = wire && (
<path
d={edgePath({ x: wire.fromX, y: wire.fromY }, { x: wire.mouseX, y: wire.mouseY })}
stroke="#818cf8" strokeWidth={1.5} fill="none" strokeDasharray="6 3"
strokeLinecap="round" opacity={0.85}
/>
)
// ── Canvas size for SVG ────────────────────────────────────────────────────
const WORLD_SIZE = 4000
const WORLD = 8000
return (
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', background: '#0a0a12' }}
ref={canvasRef}
<div ref={canvasRef}
onMouseDown={onCanvasMouseDown}
data-canvas="1"
style={{
position: 'relative', width: '100%', height: '100%',
overflow: 'hidden', background: '#0a0a14',
userSelect: 'none', WebkitUserSelect: 'none',
cursor: panDragRef.current ? 'grabbing' : 'default',
}}
>
{/* Toolbar */}
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4 }}>
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 30, display: 'flex', gap: 4, alignItems: 'center' }}>
<button onClick={() => addNode('Kernel')}
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: '1px solid #374151', background: '#1e293b', color: '#94a3b8' }}
>+ Kernel</button>
<button onClick={() => addNode('Combine')}
style={{ padding: '3px 10px', borderRadius: 4, fontSize: 11, cursor: 'pointer', border: '1px solid #374151', background: '#1e293b', color: '#94a3b8' }}
>+ Combine</button>
<span style={{ fontSize: 10, color: '#374151', alignSelf: 'center', paddingLeft: 4 }}>
scroll to zoom · drag to pan · click wire to delete
<span style={{ fontSize: 10, color: '#2d3748', paddingLeft: 4 }}>
scroll=zoom · drag=pan · click wire=delete
</span>
</div>
{/* World transform */}
<div
data-canvas="1"
<div data-canvas="1"
style={{
position: 'absolute', left: 0, top: 0,
width: WORLD_SIZE, height: WORLD_SIZE,
position: 'absolute', left: 0, top: 0, width: WORLD, height: WORLD,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
>
{/* SVG for edges */}
<svg
style={{ position: 'absolute', top: 0, left: 0, width: WORLD_SIZE, height: WORLD_SIZE, overflow: 'visible', pointerEvents: 'none' }}
>
<g style={{ pointerEvents: 'all' }}>
{svgEdges}
{/* SVG wires */}
<svg style={{ position: 'absolute', top: 0, left: 0, width: WORLD, height: WORLD, overflow: 'visible', pointerEvents: 'none' }}>
<g style={{ pointerEvents: 'stroke' }}>
{graph.edges.map((edge, idx) => {
const fn_ = nodeById[edge.from]; const tn = nodeById[edge.to]
if (!fn_ || !tn) return null
return (
<path key={idx}
d={bezier(outPort(fn_), inPort(tn, edge.port))}
stroke="#6366f1" strokeWidth={1.5} fill="none" opacity={0.75}
strokeLinecap="round" style={{ cursor: 'pointer', pointerEvents: 'stroke' }}
onClick={() => removeEdge(idx)}
/>
)
})}
</g>
{wireEdge}
{wire && (
<path
d={bezier({ x: wire.fromX, y: wire.fromY }, { x: wire.mouseX, y: wire.mouseY })}
stroke="#818cf8" strokeWidth={1.5} fill="none" strokeDasharray="6 3" strokeLinecap="round"
/>
)}
</svg>
{/* Nodes */}

View File

@@ -254,7 +254,7 @@ fn rgba_to_b64_png(rgba: &[u8], w: u32, h: u32) -> String {
}
fn map_to_b64_small(map: &[u8], w: u32, h: u32) -> String {
const MAX_DIM: u32 = 256;
const MAX_DIM: u32 = 512;
let gray = image::GrayImage::from_raw(w, h, map.to_vec())
.expect("bad map buffer");
let out = if w > MAX_DIM || h > MAX_DIM {