initial commit
This commit is contained in:
601
frontend/src/App.jsx
Normal file
601
frontend/src/App.jsx
Normal file
@@ -0,0 +1,601 @@
|
||||
import React, {
|
||||
useState, useCallback, useEffect, useRef, useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
ReactFlow, Background, Controls, MiniMap,
|
||||
useNodesState, useEdgesState, addEdge, useReactFlow,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import CustomNode, { NodeContext } from './CustomNode';
|
||||
import FileBrowser from './FileBrowser';
|
||||
import * as api from './api';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────
|
||||
|
||||
const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']);
|
||||
|
||||
const TYPE_COLORS = {
|
||||
DATA_FIELD: '#3a7abf',
|
||||
IMAGE: '#4caf50',
|
||||
LINE: '#ff9800',
|
||||
TABLE: '#fdd835',
|
||||
COORD: '#e91e63',
|
||||
};
|
||||
|
||||
const NODE_TYPES = { custom: CustomNode };
|
||||
|
||||
// ── Handle ID helpers ─────────────────────────────────────────────────
|
||||
|
||||
function getHandleType(handleId) {
|
||||
return handleId.split('::')[2];
|
||||
}
|
||||
|
||||
function getInputName(handleId) {
|
||||
return handleId.split('::')[1];
|
||||
}
|
||||
|
||||
function getOutputSlot(handleId) {
|
||||
return parseInt(handleId.split('::')[1], 10);
|
||||
}
|
||||
|
||||
// ── Graph serialisation → backend prompt format ───────────────────────
|
||||
|
||||
function serializeGraph(nodes, edges) {
|
||||
const prompt = {};
|
||||
|
||||
for (const node of nodes) {
|
||||
const { className, definition, widgetValues } = node.data;
|
||||
if (!definition) continue;
|
||||
|
||||
const inputs = {};
|
||||
|
||||
// Widget (scalar) values
|
||||
const required = definition.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
if (DATA_TYPES.has(type)) continue; // socket, handled via edges
|
||||
if (widgetValues[name] !== undefined) {
|
||||
inputs[name] = widgetValues[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Connected (socket) inputs from edges
|
||||
const incoming = edges.filter((e) => e.target === node.id);
|
||||
for (const edge of incoming) {
|
||||
const inputName = getInputName(edge.targetHandle);
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
inputs[inputName] = [edge.source, outputSlot];
|
||||
}
|
||||
|
||||
prompt[node.id] = { class_type: className, inputs };
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ── Context menu component ────────────────────────────────────────────
|
||||
|
||||
function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) {
|
||||
// Group by category, optionally filtering to compatible nodes
|
||||
const categories = {};
|
||||
for (const [className, def] of Object.entries(nodeDefs)) {
|
||||
// If filtering: only show nodes with a matching input or output
|
||||
if (filterType && filterDirection) {
|
||||
if (filterDirection === 'source') {
|
||||
// Dragged from an output — show nodes that have a matching INPUT
|
||||
const req = def.input.required || {};
|
||||
const opt = def.input.optional || {};
|
||||
const allInputs = { ...req, ...opt };
|
||||
const hasMatch = Object.values(allInputs).some((spec) => {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
return type === filterType;
|
||||
});
|
||||
if (!hasMatch) continue;
|
||||
} else {
|
||||
// Dragged from an input — show nodes that have a matching OUTPUT
|
||||
if (!def.output.includes(filterType)) continue;
|
||||
}
|
||||
}
|
||||
|
||||
const cat = def.category || 'uncategorized';
|
||||
if (!categories[cat]) categories[cat] = [];
|
||||
categories[cat].push({ className, def });
|
||||
}
|
||||
|
||||
if (Object.keys(categories).length === 0) {
|
||||
return (
|
||||
<div className="context-menu" style={{ left: x, top: y }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="context-item" style={{ color: '#64748b' }}>No compatible nodes</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="context-menu"
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{Object.entries(categories).map(([cat, items]) => (
|
||||
<div key={cat}>
|
||||
<div className="context-category">{cat}</div>
|
||||
{items.map(({ className, def }) => (
|
||||
<div
|
||||
key={className}
|
||||
className="context-item"
|
||||
onClick={() => { onAdd(className, def); onClose(); }}
|
||||
>
|
||||
{def.display_name || className}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────
|
||||
|
||||
function Flow() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [fileBrowserCb, setFileBrowserCb] = useState(null);
|
||||
|
||||
const nodeDefsRef = useRef({});
|
||||
const nextIdRef = useRef(1);
|
||||
const autoRunTimer = useRef(null);
|
||||
const autoRunRef = useRef(null);
|
||||
const reactFlow = useReactFlow();
|
||||
|
||||
// ── Load node definitions ───────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
api.getNodes().then((defs) => {
|
||||
nodeDefsRef.current = defs;
|
||||
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
|
||||
}).catch((err) => {
|
||||
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── WebSocket ───────────────────────────────────────────────────────
|
||||
|
||||
const updateNodeData = useCallback((nodeId, patch) => {
|
||||
setNodes((ns) => ns.map((n) =>
|
||||
n.id !== nodeId ? n : { ...n, data: { ...n.data, ...patch } }
|
||||
));
|
||||
}, [setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
api.setMessageHandler((msg) => {
|
||||
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
|
||||
switch (msg.type) {
|
||||
case 'execution_start':
|
||||
setStatus({ text: 'Running workflow…', level: 'info' });
|
||||
break;
|
||||
case 'executing':
|
||||
setStatus({ text: `Executing node ${msg.data.node}…`, level: 'info' });
|
||||
break;
|
||||
case 'execution_complete':
|
||||
setStatus({ text: 'Done.', level: 'info' });
|
||||
break;
|
||||
case 'execution_error':
|
||||
setStatus({ text: 'Error: ' + msg.data.message, level: 'error' });
|
||||
console.error('[argonode] execution error', msg.data);
|
||||
break;
|
||||
case 'preview':
|
||||
updateNodeData(msg.data.node_id, { previewImage: msg.data.image });
|
||||
break;
|
||||
case 'table':
|
||||
updateNodeData(msg.data.node_id, { tableRows: msg.data.rows });
|
||||
break;
|
||||
case 'mesh3d':
|
||||
updateNodeData(msg.data.node_id, { meshData: msg.data.mesh });
|
||||
break;
|
||||
case 'overlay':
|
||||
updateNodeData(msg.data.node_id, { overlay: msg.data.overlay });
|
||||
break;
|
||||
}
|
||||
});
|
||||
api.initWS();
|
||||
return () => api.closeWS();
|
||||
}, [updateNodeData]);
|
||||
|
||||
// ── Connection handling ─────────────────────────────────────────────
|
||||
|
||||
const isValidConnection = useCallback((connection) => {
|
||||
const srcType = getHandleType(connection.sourceHandle);
|
||||
const tgtType = getHandleType(connection.targetHandle);
|
||||
return srcType === tgtType;
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback((params) => {
|
||||
const type = getHandleType(params.sourceHandle);
|
||||
const color = TYPE_COLORS[type] || '#999';
|
||||
|
||||
setEdges((eds) => {
|
||||
// Enforce single connection per input handle
|
||||
const filtered = eds.filter(
|
||||
(e) => !(e.target === params.target && e.targetHandle === params.targetHandle)
|
||||
);
|
||||
return addEdge(
|
||||
{ ...params, style: { stroke: color, strokeWidth: 2 } },
|
||||
filtered
|
||||
);
|
||||
});
|
||||
scheduleAutoRun();
|
||||
}, [setEdges]);
|
||||
|
||||
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
||||
|
||||
const onConnectEnd = useCallback((event, connectionState) => {
|
||||
// If the connection was completed (dropped on a valid handle), do nothing
|
||||
if (connectionState.isValid) return;
|
||||
|
||||
const fromHandle = connectionState.fromHandle;
|
||||
if (!fromHandle || !fromHandle.id) return;
|
||||
|
||||
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
|
||||
const handleType = getHandleType(fromHandle.id);
|
||||
|
||||
setContextMenu({
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
filterType: handleType,
|
||||
filterDirection: fromHandle.type,
|
||||
pendingNodeId: fromHandle.nodeId,
|
||||
pendingHandleId: fromHandle.id,
|
||||
pendingHandleType: fromHandle.type,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Widget change callback ──────────────────────────────────────────
|
||||
|
||||
const onWidgetChange = useCallback((nodeId, name, value) => {
|
||||
setNodes((ns) => ns.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
widgetValues: { ...n.data.widgetValues, [name]: value },
|
||||
},
|
||||
};
|
||||
}));
|
||||
scheduleAutoRun();
|
||||
}, [setNodes]); // scheduleAutoRun is stable (no deps)
|
||||
|
||||
// ── File browser ────────────────────────────────────────────────────
|
||||
|
||||
const openFileBrowser = useCallback((callback) => {
|
||||
setFileBrowserCb(() => callback);
|
||||
}, []);
|
||||
|
||||
// ── Node context value (stable) ─────────────────────────────────────
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
onWidgetChange,
|
||||
openFileBrowser,
|
||||
}), [onWidgetChange, openFileBrowser]);
|
||||
|
||||
// ── Add node from context menu ──────────────────────────────────────
|
||||
|
||||
const addNode = useCallback((className, def) => {
|
||||
if (!contextMenu) return;
|
||||
const position = reactFlow.screenToFlowPosition({
|
||||
x: contextMenu.x,
|
||||
y: contextMenu.y,
|
||||
});
|
||||
|
||||
// Build default widget values
|
||||
const widgetValues = {};
|
||||
const required = def.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
||||
if (DATA_TYPES.has(type)) continue;
|
||||
if (Array.isArray(type)) {
|
||||
widgetValues[name] = type[0]; // combo default = first option
|
||||
} else {
|
||||
widgetValues[name] = opts?.default ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
const newNodeId = String(nextIdRef.current++);
|
||||
const newNode = {
|
||||
id: newNodeId,
|
||||
type: 'custom',
|
||||
position,
|
||||
dragHandle: '.drag-handle',
|
||||
data: {
|
||||
label: def.display_name || className,
|
||||
className,
|
||||
definition: def,
|
||||
widgetValues,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
overlay: null,
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((ns) => [...ns, newNode]);
|
||||
|
||||
// Auto-connect if this was triggered by dropping a connection on blank space
|
||||
if (contextMenu.pendingHandleId) {
|
||||
const filterType = contextMenu.filterType;
|
||||
|
||||
if (contextMenu.pendingHandleType === 'source') {
|
||||
// Dragged from an output → connect to the first matching input on the new node
|
||||
const allInputs = { ...(def.input.required || {}), ...(def.input.optional || {}) };
|
||||
const inputName = Object.entries(allInputs).find(([, spec]) => {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
return type === filterType;
|
||||
})?.[0];
|
||||
if (inputName) {
|
||||
const targetHandle = `input::${inputName}::${filterType}`;
|
||||
const color = TYPE_COLORS[filterType] || '#999';
|
||||
setEdges((eds) => addEdge({
|
||||
source: contextMenu.pendingNodeId,
|
||||
sourceHandle: contextMenu.pendingHandleId,
|
||||
target: newNodeId,
|
||||
targetHandle,
|
||||
style: { stroke: color, strokeWidth: 2 },
|
||||
}, eds));
|
||||
}
|
||||
} else {
|
||||
// Dragged from an input → connect from the first matching output on the new node
|
||||
const outputIdx = def.output.indexOf(filterType);
|
||||
if (outputIdx !== -1) {
|
||||
const sourceHandle = `output::${outputIdx}::${filterType}`;
|
||||
const color = TYPE_COLORS[filterType] || '#999';
|
||||
setEdges((eds) => addEdge({
|
||||
source: newNodeId,
|
||||
sourceHandle,
|
||||
target: contextMenu.pendingNodeId,
|
||||
targetHandle: contextMenu.pendingHandleId,
|
||||
style: { stroke: color, strokeWidth: 2 },
|
||||
}, eds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContextMenu(null);
|
||||
scheduleAutoRun();
|
||||
}, [contextMenu, reactFlow, setNodes, setEdges]);
|
||||
|
||||
// ── Toolbar actions ─────────────────────────────────────────────────
|
||||
|
||||
const runWorkflow = useCallback(async () => {
|
||||
// Read current state via functional ref to avoid stale closure
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
const prompt = serializeGraph(currentNodes, currentEdges);
|
||||
|
||||
if (!prompt || Object.keys(prompt).length === 0) {
|
||||
setStatus({ text: 'Graph is empty — add some nodes first.', level: 'error' });
|
||||
return;
|
||||
}
|
||||
setStatus({ text: 'Running…', level: 'info' });
|
||||
try {
|
||||
await api.runPrompt(prompt);
|
||||
} catch (err) {
|
||||
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
||||
}
|
||||
}, [reactFlow]);
|
||||
|
||||
// Debounced auto-run via ref to avoid dependency chains
|
||||
autoRunRef.current = () => {
|
||||
const currentNodes = reactFlow.getNodes();
|
||||
const currentEdges = reactFlow.getEdges();
|
||||
|
||||
// Don't run if any node has unconnected required data inputs
|
||||
for (const node of currentNodes) {
|
||||
const def = node.data?.definition;
|
||||
if (!def) continue;
|
||||
const required = def.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
if (!DATA_TYPES.has(type)) continue;
|
||||
const hasEdge = currentEdges.some(
|
||||
(e) => e.target === node.id && getInputName(e.targetHandle) === name
|
||||
);
|
||||
if (!hasEdge) return; // incomplete graph, skip auto-run
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = serializeGraph(currentNodes, currentEdges);
|
||||
if (!prompt || Object.keys(prompt).length === 0) return;
|
||||
setStatus({ text: 'Running…', level: 'info' });
|
||||
api.runPrompt(prompt).catch((err) => {
|
||||
setStatus({ text: 'Failed: ' + err.message, level: 'error' });
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleAutoRun = useCallback(() => {
|
||||
clearTimeout(autoRunTimer.current);
|
||||
autoRunTimer.current = setTimeout(() => autoRunRef.current?.(), 300);
|
||||
}, []);
|
||||
|
||||
const clearGraph = useCallback(() => {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
nextIdRef.current = 1;
|
||||
setStatus({ text: 'Graph cleared.', level: 'info' });
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const saveWorkflow = useCallback(() => {
|
||||
const currentNodes = reactFlow.getNodes().map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, previewImage: null, tableRows: null, meshData: null, overlay: null },
|
||||
}));
|
||||
const data = { version: 1, nodes: currentNodes, edges: reactFlow.getEdges() };
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'workflow.json';
|
||||
a.click();
|
||||
}, [reactFlow]);
|
||||
|
||||
const loadWorkflow = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
const loadedNodes = data.nodes || [];
|
||||
const loadedEdges = data.edges || [];
|
||||
|
||||
// Re-populate definitions from current nodeDefs
|
||||
const defs = nodeDefsRef.current;
|
||||
const hydrated = loadedNodes.map((n) => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
definition: defs[n.data.className] || n.data.definition,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
overlay: null,
|
||||
},
|
||||
}));
|
||||
|
||||
setNodes(hydrated);
|
||||
setEdges(loadedEdges);
|
||||
|
||||
// Update ID counter to avoid collisions
|
||||
const maxId = Math.max(0, ...loadedNodes.map((n) => parseInt(n.id, 10) || 0));
|
||||
nextIdRef.current = maxId + 1;
|
||||
|
||||
setStatus({ text: 'Workflow loaded.', level: 'info' });
|
||||
} catch {
|
||||
setStatus({ text: 'Invalid workflow JSON.', level: 'error' });
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
// ── Keyboard shortcut ───────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runWorkflow();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [runWorkflow]);
|
||||
|
||||
// ── Context menu ────────────────────────────────────────────────────
|
||||
|
||||
const onPaneContextMenu = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({ x: event.clientX, y: event.clientY });
|
||||
}, []);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<NodeContext.Provider value={contextValue}>
|
||||
<div className="app-container">
|
||||
{/* Toolbar */}
|
||||
<div id="toolbar">
|
||||
<span id="app-title">Argonode</span>
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button className="btn btn-primary" onClick={runWorkflow} title="Run workflow (Ctrl+Enter)">
|
||||
▶ Run
|
||||
</button>
|
||||
<button className="btn" onClick={clearGraph} title="Clear graph">
|
||||
✕ Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button className="btn" onClick={saveWorkflow} title="Save workflow JSON">
|
||||
⤓ Save
|
||||
</button>
|
||||
<button className="btn" onClick={loadWorkflow} title="Load workflow JSON">
|
||||
⤒ Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`status-bar ${status.level}`}>{status.text}</div>
|
||||
</div>
|
||||
|
||||
{/* React Flow canvas */}
|
||||
<div className="flow-container" onMouseDown={(e) => {
|
||||
if (!e.target.closest('.context-menu')) setContextMenu(null);
|
||||
}}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={onConnectEnd}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onPaneContextMenu={onPaneContextMenu}
|
||||
colorMode="dark"
|
||||
fitView
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
defaultEdgeOptions={{ type: 'default' }}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(n) => {
|
||||
const cat = n.data?.definition?.category;
|
||||
const colors = {
|
||||
io: '#37474f', filters: '#1a237e', level: '#1b5e20',
|
||||
analysis: '#4a148c', grains: '#bf360c', display: '#212121',
|
||||
};
|
||||
return colors[cat] || '#333';
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
nodeDefs={nodeDefsRef.current}
|
||||
onAdd={addNode}
|
||||
onClose={() => setContextMenu(null)}
|
||||
filterType={contextMenu.filterType}
|
||||
filterDirection={contextMenu.filterDirection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File browser modal */}
|
||||
{fileBrowserCb && (
|
||||
<FileBrowser
|
||||
onSelect={(path) => { fileBrowserCb(path); setFileBrowserCb(null); }}
|
||||
onClose={() => setFileBrowserCb(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App wrapper with ReactFlowProvider ────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<Flow />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
86
frontend/src/CrossSectionOverlay.jsx
Normal file
86
frontend/src/CrossSectionOverlay.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Image preview with two endpoint markers for cross-section line control.
|
||||
* Markers are draggable when unlocked (no COORD input connected),
|
||||
* and fixed when locked (COORD input provides the position).
|
||||
*
|
||||
* Marker positions are driven by widget values (immediate React state),
|
||||
* not by backend overlay coords, so they move instantly during drag.
|
||||
*/
|
||||
export default function CrossSectionOverlay({
|
||||
image, x1, y1, x2, y2,
|
||||
aLocked, bLocked,
|
||||
nodeId, onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null); // 'p1' or 'p2'
|
||||
|
||||
const getCoords = useCallback((e) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
return {
|
||||
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((point) => (e) => {
|
||||
if (point === 'p1' && aLocked) return;
|
||||
if (point === 'p2' && bLocked) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
e.target.setPointerCapture(e.pointerId);
|
||||
setDragging(point);
|
||||
}, [aLocked, bLocked]);
|
||||
|
||||
const onPointerMove = useCallback((e) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(e);
|
||||
const vx = parseFloat(fx.toFixed(3));
|
||||
const vy = parseFloat(fy.toFixed(3));
|
||||
if (dragging === 'p1') {
|
||||
onWidgetChange(nodeId, 'x1', vx);
|
||||
onWidgetChange(nodeId, 'y1', vy);
|
||||
} else {
|
||||
onWidgetChange(nodeId, 'x2', vx);
|
||||
onWidgetChange(nodeId, 'y2', vy);
|
||||
}
|
||||
}, [dragging, nodeId, onWidgetChange, getCoords]);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
setDragging(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag nowheel cs-overlay"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onLostPointerCapture={onPointerUp}
|
||||
>
|
||||
<img src={image} alt="field" draggable={false} className="cs-image" />
|
||||
|
||||
{/* Line connecting the two markers */}
|
||||
<svg className="cs-svg">
|
||||
<line
|
||||
x1={`${x1 * 100}%`} y1={`${y1 * 100}%`}
|
||||
x2={`${x2 * 100}%`} y2={`${y2 * 100}%`}
|
||||
stroke="#ffd700" strokeWidth="2" strokeDasharray="6 3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Endpoint markers — locked markers get a different style */}
|
||||
<div
|
||||
className={`cs-marker ${aLocked ? 'cs-marker-locked' : ''}`}
|
||||
style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }}
|
||||
onPointerDown={onPointerDown('p1')}
|
||||
/>
|
||||
<div
|
||||
className={`cs-marker ${bLocked ? 'cs-marker-locked' : ''}`}
|
||||
style={{ left: `${x2 * 100}%`, top: `${y2 * 100}%` }}
|
||||
onPointerDown={onPointerDown('p2')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
396
frontend/src/CustomNode.jsx
Normal file
396
frontend/src/CustomNode.jsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import React, { useContext, useRef, useCallback, useState, memo, lazy, Suspense } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
const SurfaceView = lazy(() => import('./SurfaceView'));
|
||||
const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────
|
||||
|
||||
const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']);
|
||||
|
||||
const TYPE_COLORS = {
|
||||
DATA_FIELD: '#3a7abf',
|
||||
IMAGE: '#4caf50',
|
||||
LINE: '#ff9800',
|
||||
TABLE: '#fdd835',
|
||||
COORD: '#e91e63',
|
||||
};
|
||||
|
||||
const CAT_COLORS = {
|
||||
io: '#37474f',
|
||||
filters: '#1a237e',
|
||||
level: '#1b5e20',
|
||||
analysis: '#4a148c',
|
||||
grains: '#bf360c',
|
||||
display: '#212121',
|
||||
};
|
||||
|
||||
// ── Context (provided by App) ─────────────────────────────────────────
|
||||
|
||||
export const NodeContext = React.createContext(null);
|
||||
|
||||
// ── Draggable number input ────────────────────────────────────────────
|
||||
|
||||
function DraggableNumber({ value, step, min, max, precision, onChange }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState('');
|
||||
const dragState = useRef(null);
|
||||
const elRef = useRef(null);
|
||||
|
||||
const display = precision != null ? Number(value).toFixed(precision) : String(value);
|
||||
|
||||
const clamp = useCallback((v) => {
|
||||
if (min != null && v < min) v = min;
|
||||
if (max != null && v > max) v = max;
|
||||
return v;
|
||||
}, [min, max]);
|
||||
|
||||
const onPointerDown = useCallback((e) => {
|
||||
if (editing) return;
|
||||
e.preventDefault();
|
||||
dragState.current = { startX: e.clientX, startVal: Number(value) };
|
||||
elRef.current?.setPointerCapture(e.pointerId);
|
||||
}, [editing, value]);
|
||||
|
||||
const onPointerMove = useCallback((e) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = e.clientX - dragState.current.startX;
|
||||
const delta = dx * (step || 0.01);
|
||||
const raw = dragState.current.startVal + delta;
|
||||
const rounded = precision != null
|
||||
? parseFloat(raw.toFixed(precision))
|
||||
: Math.round(raw);
|
||||
onChange(clamp(rounded));
|
||||
}, [step, precision, clamp, onChange]);
|
||||
|
||||
const onPointerUp = useCallback((e) => {
|
||||
if (!dragState.current) return;
|
||||
const dx = Math.abs(e.clientX - dragState.current.startX);
|
||||
dragState.current = null;
|
||||
// If barely moved, enter text-edit mode
|
||||
if (dx < 3) {
|
||||
setEditText(display);
|
||||
setEditing(true);
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
setEditing(false);
|
||||
const parsed = parseFloat(editText);
|
||||
if (!isNaN(parsed)) onChange(clamp(precision != null ? parseFloat(parsed.toFixed(precision)) : Math.round(parsed)));
|
||||
}, [editText, precision, clamp, onChange]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
className="nodrag drag-number-edit"
|
||||
type="text"
|
||||
autoFocus
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className="nodrag drag-number"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<span className="drag-number-val">{display}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Collapsible section ───────────────────────────────────────────────
|
||||
|
||||
function CollapsibleSection({ title, defaultOpen, children }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="collapsible">
|
||||
<button
|
||||
className="nodrag collapsible-toggle"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<span className="collapsible-arrow">{open ? '▾' : '▸'}</span>
|
||||
{title}
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CustomNode component ──────────────────────────────────────────────
|
||||
|
||||
function CustomNode({ id, data }) {
|
||||
const ctx = useContext(NodeContext);
|
||||
const def = data.definition;
|
||||
|
||||
// Parse inputs into data handles and widgets
|
||||
const required = def.input.required || {};
|
||||
const optional = def.input.optional || {};
|
||||
|
||||
const dataInputs = [];
|
||||
const widgets = [];
|
||||
|
||||
const hiddenWidgets = new Set();
|
||||
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
||||
if (DATA_TYPES.has(type)) {
|
||||
dataInputs.push({ name, type });
|
||||
} else if (opts?.hidden) {
|
||||
hiddenWidgets.add(name);
|
||||
} else {
|
||||
widgets.push({ name, type, opts: opts || {} });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, spec] of Object.entries(optional)) {
|
||||
const [type] = Array.isArray(spec) ? spec : [spec];
|
||||
dataInputs.push({ name, type });
|
||||
}
|
||||
|
||||
const outputs = def.output.map((type, i) => ({
|
||||
name: def.output_name[i] || type,
|
||||
type,
|
||||
slot: i,
|
||||
}));
|
||||
|
||||
const catColor = CAT_COLORS[def.category] || '#333';
|
||||
const maxIORows = Math.max(dataInputs.length, outputs.length);
|
||||
|
||||
return (
|
||||
<div className="custom-node">
|
||||
{/* Title */}
|
||||
<div className="node-title drag-handle" style={{ background: catColor }}>
|
||||
{data.label}
|
||||
</div>
|
||||
|
||||
<div className="node-body">
|
||||
{/* I/O rows — pair inputs[i] with outputs[i] */}
|
||||
{Array.from({ length: maxIORows }, (_, i) => {
|
||||
const inp = dataInputs[i];
|
||||
const out = outputs[i];
|
||||
return (
|
||||
<div className="io-row" key={`io-${i}`}>
|
||||
<div className="io-left">
|
||||
{inp && (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={`input::${inp.name}::${inp.type}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[inp.type] || '#999' }}
|
||||
/>
|
||||
<span className="io-label">{inp.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="io-right">
|
||||
{out && (
|
||||
<>
|
||||
<span className="io-label">{out.name}</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={`output::${out.slot}::${out.type}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[out.type] || '#999' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Widget rows */}
|
||||
{widgets.map((w) => (
|
||||
<div className="widget-row" key={w.name}>
|
||||
<WidgetControl
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Interactive 3D surface view */}
|
||||
{data.meshData && (
|
||||
<CollapsibleSection title="3D View" defaultOpen={true}>
|
||||
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading 3D...</div>}>
|
||||
<SurfaceView meshData={data.meshData} />
|
||||
</Suspense>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Collapsible preview image */}
|
||||
{data.previewImage && (
|
||||
<CollapsibleSection title="Preview" defaultOpen={true}>
|
||||
<div className="node-preview">
|
||||
<img src={data.previewImage} alt="preview" draggable={false} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Interactive cross-section overlay */}
|
||||
{data.overlay && hiddenWidgets.has('x1') && (
|
||||
<CollapsibleSection title="Cross Section" defaultOpen={true}>
|
||||
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}>
|
||||
<CrossSectionOverlay
|
||||
image={data.overlay.image}
|
||||
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
|
||||
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
|
||||
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
|
||||
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
|
||||
aLocked={data.overlay.a_locked}
|
||||
bLocked={data.overlay.b_locked}
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Collapsible table data */}
|
||||
{data.tableRows && data.tableRows.length > 0 && (
|
||||
<CollapsibleSection title="Table" defaultOpen={true}>
|
||||
<div className="node-table">
|
||||
{data.tableRows.map((row, i) => {
|
||||
let line;
|
||||
if (row.quantity !== undefined) {
|
||||
const val = typeof row.value === 'number' ? row.value.toExponential(3) : row.value;
|
||||
line = `${row.quantity}: ${val} ${row.unit || ''}`;
|
||||
} else {
|
||||
line = Object.entries(row)
|
||||
.slice(0, 3)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'number' ? v.toExponential(2) : v}`)
|
||||
.join(' ');
|
||||
}
|
||||
return <div key={i} className="table-line">{line}</div>;
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Widget renderer ───────────────────────────────────────────────────
|
||||
|
||||
function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) {
|
||||
const { name, type, opts } = widget;
|
||||
const val = value ?? opts?.default ?? '';
|
||||
|
||||
// Combo / enum — type itself is the array of options
|
||||
if (Array.isArray(type)) {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
<select
|
||||
className="nodrag"
|
||||
value={val || type[0]}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
>
|
||||
{type.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'FILE_PICKER') {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
<div className="file-picker-row">
|
||||
<input
|
||||
className="nodrag"
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
placeholder="Select file…"
|
||||
/>
|
||||
<button
|
||||
className="nodrag browse-btn"
|
||||
onClick={() => openFileBrowser((path) => onChange(nodeId, name, path))}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'FLOAT') {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
<DraggableNumber
|
||||
value={val || 0}
|
||||
step={opts?.step ?? 0.01}
|
||||
min={opts?.min}
|
||||
max={opts?.max}
|
||||
precision={4}
|
||||
onChange={(v) => onChange(nodeId, name, v)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'INT') {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
<DraggableNumber
|
||||
value={val || 0}
|
||||
step={opts?.step ?? 1}
|
||||
min={opts?.min}
|
||||
max={opts?.max}
|
||||
precision={0}
|
||||
onChange={(v) => onChange(nodeId, name, v)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'BOOLEAN') {
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
<input
|
||||
className="nodrag"
|
||||
type="checkbox"
|
||||
checked={!!val}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.checked)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// STRING and anything else
|
||||
return (
|
||||
<>
|
||||
<label>{name}</label>
|
||||
<input
|
||||
className="nodrag"
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CustomNode);
|
||||
94
frontend/src/FileBrowser.jsx
Normal file
94
frontend/src/FileBrowser.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import * as api from './api';
|
||||
|
||||
/**
|
||||
* Server-side file browser modal.
|
||||
*
|
||||
* Props:
|
||||
* onSelect(absolutePath) — called when user picks a file
|
||||
* onClose() — called when user dismisses the dialog
|
||||
*/
|
||||
export default function FileBrowser({ onSelect, onClose }) {
|
||||
const [path, setPath] = useState('');
|
||||
const [parent, setParent] = useState(null);
|
||||
const [dirs, setDirs] = useState([]);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const navigate = useCallback(async (dir) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.browse(dir);
|
||||
setPath(data.path);
|
||||
setParent(data.parent);
|
||||
setDirs(data.dirs);
|
||||
setFiles(data.files);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start at home directory on mount
|
||||
useEffect(() => {
|
||||
navigate(null);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="fb-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="fb-dialog">
|
||||
{/* Header */}
|
||||
<div className="fb-header">
|
||||
<span className="fb-path">{path}</span>
|
||||
<button className="fb-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="fb-list">
|
||||
{loading && <div className="fb-loading">Loading…</div>}
|
||||
{error && <div className="fb-loading">Error: {error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Parent directory */}
|
||||
{parent && (
|
||||
<div className="fb-entry fb-dir" onClick={() => navigate(parent)}>
|
||||
⬆ ..
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directories */}
|
||||
{dirs.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="fb-entry fb-dir"
|
||||
onClick={() => navigate(path + '/' + d)}
|
||||
>
|
||||
📁 {d}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Files */}
|
||||
{files.map((f) => (
|
||||
<div
|
||||
key={f}
|
||||
className="fb-entry fb-file"
|
||||
onClick={() => { onSelect(path + '/' + f); onClose(); }}
|
||||
>
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dirs.length === 0 && files.length === 0 && (
|
||||
<div className="fb-loading">Empty directory</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/SurfaceView.jsx
Normal file
183
frontend/src/SurfaceView.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
/**
|
||||
* Interactive 3D surface viewer using Three.js.
|
||||
* Props:
|
||||
* meshData: { width, height, z_data (b64 float32), colors (b64 uint8 RGB),
|
||||
* z_min, z_max, z_scale, x_range, y_range }
|
||||
*/
|
||||
export default function SurfaceView({ meshData }) {
|
||||
const containerRef = useRef(null);
|
||||
const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh }
|
||||
|
||||
// Decode base64 to typed arrays
|
||||
const decode = useCallback((b64, ArrayType) => {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new ArrayType(bytes.buffer);
|
||||
}, []);
|
||||
|
||||
// Initialize Three.js scene once
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || threeRef.current) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = width; // 1:1 aspect
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setClearColor(0x0f172a);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
|
||||
camera.position.set(1.2, 0.8, 1.2);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.minDistance = 0.3;
|
||||
controls.maxDistance = 10;
|
||||
|
||||
// Lighting
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
scene.add(ambient);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dir.position.set(1, 2, 1.5);
|
||||
scene.add(dir);
|
||||
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
dir2.position.set(-1, 0.5, -1);
|
||||
scene.add(dir2);
|
||||
|
||||
// Animation loop
|
||||
let animId;
|
||||
const animate = () => {
|
||||
animId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
threeRef.current = { renderer, scene, camera, controls, mesh: null, animId };
|
||||
|
||||
// Resize observer to maintain 1:1 aspect when node width changes
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry || !threeRef.current) return;
|
||||
const w = entry.contentRect.width;
|
||||
if (w < 1) return;
|
||||
const { renderer: r, camera: c } = threeRef.current;
|
||||
r.setSize(w, w);
|
||||
c.aspect = 1;
|
||||
c.updateProjectionMatrix();
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
cancelAnimationFrame(animId);
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update mesh when data changes
|
||||
useEffect(() => {
|
||||
if (!threeRef.current || !meshData) return;
|
||||
|
||||
const { scene, camera, controls } = threeRef.current;
|
||||
const { width: nx, height: ny, z_data, colors, z_min, z_max, z_scale, x_range, y_range } = meshData;
|
||||
|
||||
// Decode arrays
|
||||
const zArr = decode(z_data, Float32Array);
|
||||
const colArr = decode(colors, Uint8Array);
|
||||
|
||||
// Remove old mesh
|
||||
if (threeRef.current.mesh) {
|
||||
scene.remove(threeRef.current.mesh);
|
||||
threeRef.current.mesh.geometry.dispose();
|
||||
threeRef.current.mesh.material.dispose();
|
||||
}
|
||||
|
||||
// Build geometry
|
||||
const geom = new THREE.BufferGeometry();
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
const colorAttr = new Float32Array(nx * ny * 3);
|
||||
|
||||
// Normalize coordinates to roughly [-0.5, 0.5] for good camera framing
|
||||
const zRange = z_max - z_min || 1;
|
||||
|
||||
for (let iy = 0; iy < ny; iy++) {
|
||||
for (let ix = 0; ix < nx; ix++) {
|
||||
const idx = iy * nx + ix;
|
||||
const px = ix / (nx - 1) - 0.5; // [-0.5, 0.5]
|
||||
const py = iy / (ny - 1) - 0.5;
|
||||
const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale;
|
||||
|
||||
positions[idx * 3] = px;
|
||||
positions[idx * 3 + 1] = pz; // height on Y axis
|
||||
positions[idx * 3 + 2] = py;
|
||||
|
||||
colorAttr[idx * 3] = colArr[idx * 3] / 255;
|
||||
colorAttr[idx * 3 + 1] = colArr[idx * 3 + 1] / 255;
|
||||
colorAttr[idx * 3 + 2] = colArr[idx * 3 + 2] / 255;
|
||||
}
|
||||
}
|
||||
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geom.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3));
|
||||
|
||||
// Build index (triangles from grid)
|
||||
const indices = [];
|
||||
for (let iy = 0; iy < ny - 1; iy++) {
|
||||
for (let ix = 0; ix < nx - 1; ix++) {
|
||||
const a = iy * nx + ix;
|
||||
const b = a + 1;
|
||||
const c = a + nx;
|
||||
const d = c + 1;
|
||||
indices.push(a, c, b);
|
||||
indices.push(b, c, d);
|
||||
}
|
||||
}
|
||||
geom.setIndex(indices);
|
||||
geom.computeVertexNormals();
|
||||
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
vertexColors: true,
|
||||
side: THREE.DoubleSide,
|
||||
shininess: 30,
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geom, mat);
|
||||
scene.add(mesh);
|
||||
threeRef.current.mesh = mesh;
|
||||
|
||||
// Reset camera target to center of mesh
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}, [meshData, decode]);
|
||||
|
||||
// Prevent scroll events from propagating to React Flow
|
||||
const onWheel = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag nowheel surface-view-container"
|
||||
onWheelCapture={onWheel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
93
frontend/src/api.js
Normal file
93
frontend/src/api.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* api.js — REST + WebSocket client for argonode backend.
|
||||
*
|
||||
* Uses relative URLs so the Vite dev proxy (port 5173 → 8188)
|
||||
* and production same-origin serving both work transparently.
|
||||
*/
|
||||
|
||||
// ── REST helpers ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getNodes() {
|
||||
const r = await fetch('/nodes');
|
||||
if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getFiles() {
|
||||
const r = await fetch('/files');
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function browse(dir) {
|
||||
const url = dir ? `/browse?dir=${encodeURIComponent(dir)}` : '/browse';
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`Browse failed: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function uploadFile(file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch('/upload', { method: 'POST', body: fd });
|
||||
if (!r.ok) throw new Error(`Upload failed: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function runPrompt(prompt) {
|
||||
const r = await fetch('/prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
throw new Error(`POST /prompt failed (${r.status}): ${text}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────
|
||||
|
||||
let _ws = null;
|
||||
let _handler = null;
|
||||
let _reconnectTimer = null;
|
||||
|
||||
export function setMessageHandler(fn) {
|
||||
_handler = fn;
|
||||
}
|
||||
|
||||
export function initWS() {
|
||||
if (_ws && _ws.readyState < 2) return; // already open or connecting
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
_ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
|
||||
_ws.onopen = () => {
|
||||
console.log('[argonode] WebSocket connected');
|
||||
};
|
||||
|
||||
_ws.onclose = () => {
|
||||
console.log('[argonode] WebSocket closed, reconnecting in 3s…');
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(() => initWS(), 3000);
|
||||
};
|
||||
|
||||
_ws.onerror = (e) => {
|
||||
console.error('[argonode] WebSocket error', e);
|
||||
};
|
||||
|
||||
_ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (_handler) _handler(msg);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function closeWS() {
|
||||
clearTimeout(_reconnectTimer);
|
||||
if (_ws) _ws.close();
|
||||
}
|
||||
6
frontend/src/main.jsx
Normal file
6
frontend/src/main.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
509
frontend/src/styles.css
Normal file
509
frontend/src/styles.css
Normal file
@@ -0,0 +1,509 @@
|
||||
/* ── Reset & base ──────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
font-family: "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────────────────── */
|
||||
#toolbar {
|
||||
height: 44px;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#app-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: #e94560;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 5px;
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #1a4a8a;
|
||||
border-color: #3a7abf;
|
||||
}
|
||||
.btn:active {
|
||||
background: #0a2040;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #e94560;
|
||||
border-color: #e94560;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #ff6b81;
|
||||
border-color: #ff6b81;
|
||||
}
|
||||
|
||||
/* ── Status bar ────────────────────────────────────────────────────── */
|
||||
.status-bar {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
max-width: 400px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.status-bar.info { color: #90caf9; }
|
||||
.status-bar.error { color: #ef9a9a; background: rgba(183,28,28,0.2); }
|
||||
|
||||
/* ── React Flow container ──────────────────────────────────────────── */
|
||||
.flow-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── React Flow dark overrides ─────────────────────────────────────── */
|
||||
.react-flow {
|
||||
background: #0d1117 !important;
|
||||
}
|
||||
|
||||
/* ── Custom node ───────────────────────────────────────────────────── */
|
||||
.custom-node {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
width: 200px;
|
||||
min-width: 160px;
|
||||
resize: horizontal;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Let React Flow node wrapper fit to the custom-node's size */
|
||||
.react-flow__node-custom {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Title bar is the drag handle for moving the node */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.custom-node.selected {
|
||||
border-color: #90caf9;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
padding: 5px 10px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
border-radius: 5px 5px 0 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.node-body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ── I/O rows ──────────────────────────────────────────────────────── */
|
||||
.io-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 12px;
|
||||
min-height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.io-left, .io-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.io-label {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Handles ───────────────────────────────────────────────────────── */
|
||||
.typed-handle {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
border: 2px solid #1e293b !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* ── Widget rows ───────────────────────────────────────────────────── */
|
||||
.widget-row {
|
||||
padding: 3px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.widget-row label {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
min-width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-row input[type="text"],
|
||||
.widget-row input[type="number"],
|
||||
.widget-row select {
|
||||
background: #0f172a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.widget-row input[type="checkbox"] {
|
||||
accent-color: #3a7abf;
|
||||
}
|
||||
|
||||
.widget-row input:focus,
|
||||
.widget-row select:focus {
|
||||
outline: none;
|
||||
border-color: #3a7abf;
|
||||
}
|
||||
|
||||
.file-picker-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-picker-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Draggable number ──────────────────────────────────────────────── */
|
||||
.drag-number {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
touch-action: none;
|
||||
}
|
||||
.drag-number:hover {
|
||||
border-color: #3a7abf;
|
||||
}
|
||||
.drag-number-val {
|
||||
pointer-events: none;
|
||||
}
|
||||
.drag-number-edit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #3a7abf;
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.browse-btn {
|
||||
background: #0f3460;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.browse-btn:hover {
|
||||
background: #1a4a8a;
|
||||
}
|
||||
|
||||
/* ── Collapsible section ───────────────────────────────────────────── */
|
||||
.collapsible {
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.collapsible-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.collapsible-toggle:hover {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.collapsible-arrow {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* ── Node preview ──────────────────────────────────────────────────── */
|
||||
.node-preview {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node-preview img {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Cross-section overlay ────────────────────────────────────────── */
|
||||
.cs-overlay {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cs-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.cs-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cs-marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #ffd700;
|
||||
border: 2px solid #fff;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 4px rgba(0,0,0,0.6);
|
||||
z-index: 1;
|
||||
}
|
||||
.cs-marker:active:not(.cs-marker-locked) {
|
||||
cursor: grabbing;
|
||||
background: #ffeb3b;
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
.cs-marker-locked {
|
||||
background: #e91e63;
|
||||
border-color: #e91e63;
|
||||
cursor: default;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ── 3D surface view ──────────────────────────────────────────────── */
|
||||
.surface-view-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
cursor: grab;
|
||||
overflow: hidden;
|
||||
}
|
||||
.surface-view-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.surface-view-container canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Node table ────────────────────────────────────────────────────── */
|
||||
.node-table {
|
||||
padding: 4px 10px;
|
||||
font-family: "SF Mono", "Fira Code", monospace;
|
||||
font-size: 10px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.table-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Node resize handles ───────────────────────────────────────────── */
|
||||
.node-resize-line {
|
||||
border-color: #90caf9 !important;
|
||||
}
|
||||
.node-resize-handle {
|
||||
background: #90caf9 !important;
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
}
|
||||
|
||||
/* ── Context menu ──────────────────────────────────────────────────── */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 6px;
|
||||
min-width: 180px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.context-category {
|
||||
padding: 6px 12px 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid #0f3460;
|
||||
}
|
||||
.context-category:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.context-item {
|
||||
padding: 5px 20px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.context-item:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
/* ── File browser dialog ──────────────────────────────────────────── */
|
||||
.fb-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.fb-dialog {
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 8px;
|
||||
width: 520px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.fb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
.fb-path {
|
||||
font-size: 12px;
|
||||
color: #90caf9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.fb-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.fb-close:hover { color: #e94560; }
|
||||
.fb-list {
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
flex: 1;
|
||||
}
|
||||
.fb-entry {
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fb-entry:hover { background: #0f3460; }
|
||||
.fb-dir { color: #90caf9; }
|
||||
.fb-file { color: #e0e0e0; }
|
||||
.fb-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #607d8b;
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ─────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #1a1a2e; }
|
||||
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a7abf; }
|
||||
|
||||
/* ── React Flow MiniMap ────────────────────────────────────────────── */
|
||||
.react-flow__minimap {
|
||||
background: #16213e !important;
|
||||
border: 1px solid #0f3460 !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
Reference in New Issue
Block a user