sync: contextual substrate UX from private repo
- Add DimensionIconsContext for app-wide dimension icon sharing - Enhanced getNodeIcon() with favicon/YouTube/PDF detection + dimension icons - Propagate dynamic icons to GridView, ListView, SearchModal, FocusPanel, ViewsOverlay, FolderViewOverlay - Add RahEdge component with hover labels for map edges - Update RahNode with dimension-colored borders and icon support - Add MiniMap with dimension color coding to MapPane - MCP server: add description field to rah_update_node, bump standalone to v1.4.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d6987a3dc1
commit
d723212ed3
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties }
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge as rfAddEdge,
|
||||
@@ -22,8 +23,10 @@ import type { MapPaneProps } from './types';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { RahNode } from './map/RahNode';
|
||||
import { RahEdge } from './map/RahEdge';
|
||||
import EdgeExplanationModal from './map/EdgeExplanationModal';
|
||||
import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import './map/map-styles.css';
|
||||
|
||||
interface DimensionInfo {
|
||||
@@ -34,6 +37,7 @@ interface DimensionInfo {
|
||||
}
|
||||
|
||||
const nodeTypes = { rahNode: RahNode };
|
||||
const edgeTypes = { rahEdge: RahEdge };
|
||||
|
||||
// Debounce helper
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -57,6 +61,7 @@ function MapPaneInner({
|
||||
activeTabId,
|
||||
}: MapPaneProps) {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
|
||||
// --- Data state (DB-level) ---
|
||||
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]);
|
||||
@@ -190,6 +195,7 @@ function MapPaneInner({
|
||||
selectedNodeId,
|
||||
connectedNodeIds,
|
||||
rfPositionsRef.current,
|
||||
dimensionIcons,
|
||||
);
|
||||
|
||||
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
|
||||
@@ -590,15 +596,26 @@ function MapPaneInner({
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={3}
|
||||
defaultEdgeOptions={{ type: 'default' }}
|
||||
defaultEdgeOptions={{ type: 'rahEdge' }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode="dark"
|
||||
>
|
||||
<Background color="#1a1a1a" gap={40} size={1} />
|
||||
<MiniMap
|
||||
style={{ background: '#0a0a0a', border: '1px solid #1f1f1f', borderRadius: 6 }}
|
||||
maskColor="rgba(0,0,0,0.6)"
|
||||
nodeColor={(n) => {
|
||||
const data = n.data as RahNodeData | undefined;
|
||||
return data?.primaryDimensionColor || '#374151';
|
||||
}}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Selected node info panel */}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from 'react';
|
||||
import {
|
||||
BaseEdge,
|
||||
getStraightPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react';
|
||||
|
||||
interface RahEdgeData {
|
||||
explanation?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function RahEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
style,
|
||||
data,
|
||||
...rest
|
||||
}: EdgeProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const explanation = (data as RahEdgeData | undefined)?.explanation;
|
||||
|
||||
const [edgePath, labelX, labelY] = getStraightPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
});
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Invisible wider path for easier hover targeting */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={12}
|
||||
style={{ cursor: 'default' }}
|
||||
/>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
{hovered && explanation && (
|
||||
<foreignObject
|
||||
x={labelX - 80}
|
||||
y={labelY - 14}
|
||||
width={160}
|
||||
height={28}
|
||||
style={{ overflow: 'visible', pointerEvents: 'none' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 10,
|
||||
color: '#ccc',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 160,
|
||||
}}
|
||||
>
|
||||
{explanation}
|
||||
</div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export const RahEdge = memo(RahEdgeComponent);
|
||||
@@ -4,11 +4,12 @@ import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||
import type { RahNodeData } from './utils';
|
||||
import { LABEL_THRESHOLD } from './utils';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
|
||||
type RahNodeType = Node<RahNodeData, 'rahNode'>;
|
||||
|
||||
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
const { label, dimensions, edgeCount, isExpanded } = data;
|
||||
const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data;
|
||||
const isTop = !isExpanded && edgeCount > 3;
|
||||
|
||||
return (
|
||||
@@ -19,6 +20,7 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
isTop && 'rah-map-node--top',
|
||||
selected && 'rah-map-node--selected',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
@@ -26,7 +28,10 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
className="rah-map-handle"
|
||||
/>
|
||||
<div className="rah-map-node__title">
|
||||
{label.length > 28 ? label.slice(0, 26) + '\u2026' : label}
|
||||
<span className="rah-map-node__icon">
|
||||
{getNodeIcon(dbNode, dimensionIcons, 14)}
|
||||
</span>
|
||||
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
|
||||
</div>
|
||||
{(isTop || isExpanded) && dimensions.length > 0 && (
|
||||
<div className="rah-map-node__dims">
|
||||
|
||||
@@ -89,6 +89,15 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rah-map-node__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rah-map-node__dims {
|
||||
@@ -130,3 +139,10 @@
|
||||
.rah-map-wrapper .react-flow__node.dimmed:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* MiniMap dark theme */
|
||||
.rah-map-wrapper .react-flow__minimap {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
|
||||
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
|
||||
|
||||
// Fixed palette for dimension border colors (muted, dark-theme-friendly)
|
||||
const DIMENSION_COLORS = [
|
||||
'#22c55e', // green
|
||||
'#3b82f6', // blue
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#a855f7', // purple
|
||||
];
|
||||
|
||||
function hashDimensionColor(dimension: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < dimension.length; i++) {
|
||||
hash = ((hash << 5) - hash + dimension.charCodeAt(i)) | 0;
|
||||
}
|
||||
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
|
||||
}
|
||||
|
||||
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
|
||||
if (!dimensions || dimensions.length === 0) return undefined;
|
||||
// Use first dimension for border color
|
||||
return hashDimensionColor(dimensions[0]);
|
||||
}
|
||||
|
||||
export interface RahNodeData {
|
||||
label: string;
|
||||
dimensions: string[];
|
||||
edgeCount: number;
|
||||
isExpanded: boolean;
|
||||
dbNode: DbNode;
|
||||
dimensionIcons?: Record<string, string>;
|
||||
primaryDimensionColor?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -83,6 +113,7 @@ export function toRFNodes(
|
||||
selectedNodeId: number | null,
|
||||
connectedNodeIds: Set<number>,
|
||||
existingPositions: Map<string, { x: number; y: number }>,
|
||||
dimensionIcons?: Record<string, string>,
|
||||
): 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);
|
||||
@@ -109,6 +140,8 @@ export function toRFNodes(
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: false,
|
||||
dbNode: node,
|
||||
dimensionIcons,
|
||||
primaryDimensionColor: getDimensionColor(node.dimensions),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -154,6 +187,8 @@ export function toRFNodes(
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: true,
|
||||
dbNode: node,
|
||||
dimensionIcons,
|
||||
primaryDimensionColor: getDimensionColor(node.dimensions),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -181,11 +216,15 @@ export function toRFEdges(
|
||||
);
|
||||
const isDimmed = hasSelection && !isConnected;
|
||||
|
||||
const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : '';
|
||||
|
||||
return {
|
||||
id: String(e.id),
|
||||
source: String(e.from_node_id),
|
||||
target: String(e.to_node_id),
|
||||
type: 'rahEdge',
|
||||
animated: isConnected,
|
||||
data: { explanation },
|
||||
style: isConnected
|
||||
? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 }
|
||||
: isDimmed
|
||||
|
||||
Reference in New Issue
Block a user