sync: interactive map canvas from private repo

- React Flow replaces custom SVG map (node dragging, edge creation, SSE sync)
- Redesigned QuickAddInput and EdgeExplanationModal (match SearchModal design)
- Selection highlighting with edge/node dimming
- Added tabBar prop to BasePaneProps and PaneHeaderProps
- Removed stale activeDelegations prop from QuickAddInput callers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-28 13:01:46 +11:00
co-authored by Claude Opus 4.5
parent 05523b7cb2
commit 65d22315e1
11 changed files with 1542 additions and 753 deletions
@@ -0,0 +1,262 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface EdgeExplanationModalProps {
sourceTitle: string;
targetTitle: string;
onSubmit: (explanation: string) => void;
onCancel: () => void;
}
export default function EdgeExplanationModal({
sourceTitle,
targetTitle,
onSubmit,
onCancel,
}: EdgeExplanationModalProps) {
const [explanation, setExplanation] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && explanation.trim()) {
e.preventDefault();
onSubmit(explanation.trim());
}
};
const modalContent = (
<div
className="edge-modal-backdrop"
onClick={onCancel}
role="dialog"
aria-modal="true"
aria-label="Connect nodes"
>
<div className="edge-modal-container">
<div
className="edge-modal-card"
onClick={(e) => e.stopPropagation()}
>
{/* Connection visualization */}
<div className="edge-modal-connection">
<span className="edge-modal-node-badge">{sourceTitle.length > 24 ? sourceTitle.slice(0, 22) + '\u2026' : sourceTitle}</span>
<svg width="20" height="12" viewBox="0 0 20 12" fill="none" style={{ flexShrink: 0 }}>
<path d="M0 6h16M12 1l5 5-5 5" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="edge-modal-node-badge">{targetTitle.length > 24 ? targetTitle.slice(0, 22) + '\u2026' : targetTitle}</span>
</div>
{/* Textarea */}
<div className="edge-modal-input-wrapper">
<textarea
ref={textareaRef}
value={explanation}
onChange={(e) => setExplanation(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="How are these connected?"
rows={2}
className="edge-modal-textarea"
/>
</div>
{/* Footer */}
<div className="edge-modal-footer">
<span className="edge-modal-hint">
<kbd>&#x2318;&#x21B5;</kbd> connect &middot; <kbd>esc</kbd> cancel
</span>
<div className="edge-modal-actions">
<button onClick={onCancel} className="edge-modal-btn edge-modal-btn--cancel">
Cancel
</button>
<button
onClick={() => explanation.trim() && onSubmit(explanation.trim())}
disabled={!explanation.trim()}
className={`edge-modal-btn edge-modal-btn--submit ${explanation.trim() ? 'active' : ''}`}
>
Connect
</button>
</div>
</div>
</div>
</div>
<style jsx>{`
.edge-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 18vh;
z-index: 9999;
animation: edgeBackdropIn 200ms ease-out;
}
.edge-modal-container {
width: 100%;
max-width: 480px;
animation: edgeContainerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.edge-modal-card {
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
padding: 24px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
}
.edge-modal-connection {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
margin-bottom: 20px;
}
.edge-modal-node-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.15);
border-radius: 8px;
color: #86efac;
font-size: 13px;
font-weight: 500;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edge-modal-input-wrapper {
border: 1px solid #262626;
border-radius: 12px;
background: #0a0a0a;
transition: border-color 0.15s ease;
}
.edge-modal-input-wrapper:focus-within {
border-color: #333;
}
.edge-modal-textarea {
width: 100%;
background: transparent;
border: none;
padding: 16px 18px;
color: #fafafa;
font-size: 15px;
font-family: inherit;
resize: none;
outline: none;
line-height: 1.5;
}
.edge-modal-textarea::placeholder {
color: #525252;
}
.edge-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.edge-modal-hint {
font-size: 11px;
color: #525252;
}
.edge-modal-hint kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
background: #262626;
border-radius: 4px;
font-size: 10px;
font-family: inherit;
color: #737373;
border: 1px solid #333;
}
.edge-modal-actions {
display: flex;
gap: 8px;
}
.edge-modal-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.edge-modal-btn--cancel {
background: transparent;
border: 1px solid #262626;
color: #737373;
}
.edge-modal-btn--cancel:hover {
border-color: #333;
color: #a3a3a3;
}
.edge-modal-btn--submit {
background: #262626;
border: 1px solid transparent;
color: #525252;
cursor: default;
}
.edge-modal-btn--submit.active {
background: #22c55e;
color: #052e16;
cursor: pointer;
}
.edge-modal-btn--submit.active:hover {
background: #16a34a;
}
@keyframes edgeBackdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes edgeContainerIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
`}</style>
</div>
);
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import type { RahNodeData } from './utils';
import { LABEL_THRESHOLD } from './utils';
type RahNodeType = Node<RahNodeData, 'rahNode'>;
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
const { label, dimensions, edgeCount, isExpanded } = data;
const isTop = !isExpanded && edgeCount > 3;
return (
<div
className={[
'rah-map-node',
isExpanded && 'rah-map-node--expanded',
isTop && 'rah-map-node--top',
selected && 'rah-map-node--selected',
].filter(Boolean).join(' ')}
>
<Handle
type="target"
position={Position.Top}
className="rah-map-handle"
/>
<div className="rah-map-node__title">
{label.length > 28 ? label.slice(0, 26) + '\u2026' : label}
</div>
{(isTop || isExpanded) && dimensions.length > 0 && (
<div className="rah-map-node__dims">
{dimensions.slice(0, 3).map(d => d.length > 12 ? d.slice(0, 11) + '\u2026' : d).join(' \u00b7 ')}
</div>
)}
<Handle
type="source"
position={Position.Bottom}
className="rah-map-handle"
/>
</div>
);
}
export const RahNode = memo(RahNodeComponent);
+132
View File
@@ -0,0 +1,132 @@
/* React Flow dark theme overrides for RA-H Map */
.rah-map-wrapper .react-flow__background {
background: #080808 !important;
}
.rah-map-wrapper .react-flow__pane {
cursor: grab;
}
.rah-map-wrapper .react-flow__pane:active {
cursor: grabbing;
}
/* Edges */
.rah-map-wrapper .react-flow__edge-path {
stroke: #374151;
stroke-width: 1.5;
}
.rah-map-wrapper .react-flow__edge.selected .react-flow__edge-path {
stroke: #22c55e;
stroke-width: 2;
}
/* Connection line while dragging */
.rah-map-wrapper .react-flow__connection-path {
stroke: #22c55e;
stroke-width: 2;
stroke-dasharray: 5 3;
}
/* Attribution */
.rah-map-wrapper .react-flow__attribution {
display: none;
}
/* Controls */
.rah-map-wrapper .react-flow__controls {
display: none;
}
/* Selection box */
.rah-map-wrapper .react-flow__selection {
border: 1px solid rgba(34, 197, 94, 0.4);
background: rgba(34, 197, 94, 0.05);
}
/* Custom node styles */
.rah-map-node {
background: #0f0f0f;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 8px 12px;
color: #e5e7eb;
font-size: 12px;
min-width: 60px;
max-width: 180px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
}
.rah-map-node:hover {
border-color: #3a3a3a;
}
.rah-map-node--selected {
border-color: #22c55e !important;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.rah-map-node--top {
border-color: #166534;
background: #0a1a0f;
}
.rah-map-node--expanded {
border-color: #b45309;
background: #1a150a;
}
.rah-map-node--expanded .rah-map-node__title {
color: #fbbf24;
}
.rah-map-node__title {
font-weight: 500;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rah-map-node__dims {
margin-top: 4px;
font-size: 10px;
color: #a1a1aa;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Handles */
.rah-map-handle {
width: 8px !important;
height: 8px !important;
background: #22c55e !important;
border: 2px solid #0f0f0f !important;
opacity: 0;
transition: opacity 0.15s ease;
}
.rah-map-node:hover .rah-map-handle,
.react-flow__node.selected .rah-map-handle {
opacity: 1;
}
/* Connecting state: show all handles */
.rah-map-wrapper .react-flow__node.connecting .rah-map-handle,
.rah-map-wrapper.connecting .rah-map-handle {
opacity: 1;
}
/* Dimmed nodes (not selected or connected to selection) */
.rah-map-wrapper .react-flow__node.dimmed {
opacity: 0.2;
transition: opacity 0.2s ease;
}
.rah-map-wrapper .react-flow__node.dimmed:hover {
opacity: 0.6;
}
+206
View File
@@ -0,0 +1,206 @@
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
export interface RahNodeData {
label: string;
dimensions: string[];
edgeCount: number;
isExpanded: boolean;
dbNode: DbNode;
[key: string]: unknown;
}
const NODE_LIMIT = 200;
const LABEL_THRESHOLD = 15;
export { NODE_LIMIT, LABEL_THRESHOLD };
/**
* Get node position from saved metadata or calculate using Fibonacci spiral.
*/
export function getNodePosition(
node: DbNode,
index: number,
total: number,
centerX: number,
centerY: number,
maxEdges: number,
): { x: number; y: number } {
// Check for saved position in metadata
const metadata = typeof node.metadata === 'string'
? safeParseJSON(node.metadata)
: node.metadata;
const savedPos = metadata?.map_position;
if (savedPos?.x !== undefined && savedPos?.y !== undefined) {
return { x: savedPos.x, y: savedPos.y };
}
// Fibonacci spiral layout
const edgeCount = node.edge_count ?? 0;
const edgeRatio = maxEdges > 0 ? edgeCount / maxEdges : 0;
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const angle = index * goldenAngle;
const isLabeled = index < LABEL_THRESHOLD;
const labelSpacing = isLabeled ? 60 : 0;
const containerSize = Math.min(centerX * 2, centerY * 2);
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * containerSize * 0.35;
const distance = baseDistance + index * 4;
return {
x: centerX + Math.cos(angle) * distance,
y: centerY + Math.sin(angle) * distance,
};
}
/**
* Position expanded (traversal) nodes in a circle around a reference node.
*/
export function getExpandedNodePosition(
index: number,
total: number,
refX: number,
refY: number,
): { x: number; y: number } {
const angle = (index / Math.max(total, 1)) * Math.PI * 2;
const distance = 150 + (index % 3) * 40;
return {
x: refX + Math.cos(angle) * distance,
y: refY + Math.sin(angle) * distance,
};
}
/**
* Transform DB nodes into React Flow nodes.
* When a node is selected, non-connected nodes get dimmed via className.
*/
export function toRFNodes(
baseNodes: DbNode[],
expandedNodes: DbNode[],
centerX: number,
centerY: number,
selectedNodeId: number | null,
connectedNodeIds: Set<number>,
existingPositions: Map<string, { x: number; y: number }>,
): RFNode<RahNodeData>[] {
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
const baseNodeIds = new Set(baseNodes.map(n => n.id));
const hasSelection = selectedNodeId !== null;
const rfNodes: RFNode<RahNodeData>[] = sortedBase.map((node, index) => {
const id = String(node.id);
// Prefer React Flow's current position (for drag state), then saved, then calculated
const existingPos = existingPositions.get(id);
const pos = existingPos || getNodePosition(node, index, sortedBase.length, centerX, centerY, maxEdges);
// Dim nodes that aren't selected or connected to selection
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
return {
id,
type: 'rahNode',
position: pos,
className: isDimmed ? 'dimmed' : undefined,
data: {
label: node.title || 'Untitled',
dimensions: node.dimensions || [],
edgeCount: node.edge_count ?? 0,
isExpanded: false,
dbNode: node,
},
};
});
// Add expanded nodes not already in base
const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id));
// Find reference position for expanded nodes (the selected node)
let refX = centerX;
let refY = centerY;
if (selectedNodeId) {
const selectedRF = rfNodes.find(n => n.id === String(selectedNodeId));
if (selectedRF) {
refX = selectedRF.position.x;
refY = selectedRF.position.y;
}
}
uniqueExpanded.forEach((node, index) => {
const id = String(node.id);
const existingPos = existingPositions.get(id);
// Check for saved metadata position
const metadata = typeof node.metadata === 'string'
? safeParseJSON(node.metadata)
: node.metadata;
const savedPos = metadata?.map_position;
const pos = existingPos
|| (savedPos?.x !== undefined ? { x: savedPos.x, y: savedPos.y } : null)
|| getExpandedNodePosition(index, uniqueExpanded.length, refX, refY);
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
rfNodes.push({
id,
type: 'rahNode',
position: pos,
className: isDimmed ? 'dimmed' : undefined,
data: {
label: node.title || 'Untitled',
dimensions: node.dimensions || [],
edgeCount: node.edge_count ?? 0,
isExpanded: true,
dbNode: node,
},
});
});
return rfNodes;
}
/**
* Transform DB edges into React Flow edges, filtering to only those
* connecting nodes currently in the graph.
* When a node is selected, connected edges are highlighted and others dimmed.
*/
export function toRFEdges(
dbEdges: DbEdge[],
nodeIds: Set<string>,
selectedNodeId: number | null,
): RFEdge[] {
const hasSelection = selectedNodeId !== null;
return dbEdges
.filter(e => nodeIds.has(String(e.from_node_id)) && nodeIds.has(String(e.to_node_id)))
.map(e => {
const isConnected = hasSelection && (
e.from_node_id === selectedNodeId || e.to_node_id === selectedNodeId
);
const isDimmed = hasSelection && !isConnected;
return {
id: String(e.id),
source: String(e.from_node_id),
target: String(e.to_node_id),
animated: isConnected,
style: isConnected
? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 }
: isDimmed
? { stroke: '#374151', strokeWidth: 1, opacity: 0.15 }
: undefined,
zIndex: isConnected ? 10 : 0,
};
});
}
function safeParseJSON(str: string | null | undefined): Record<string, unknown> | null {
if (!str || str === 'null') return null;
try {
return JSON.parse(str);
} catch {
return null;
}
}