) {
className="rah-map-handle"
/>
- {label.length > 28 ? label.slice(0, 26) + '\u2026' : label}
+
+ {getNodeIcon(dbNode, dimensionIcons, 14)}
+
+ {label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
{(isTop || isExpanded) && dimensions.length > 0 && (
diff --git a/src/components/panes/map/map-styles.css b/src/components/panes/map/map-styles.css
index bef4752..7b0b379 100644
--- a/src/components/panes/map/map-styles.css
+++ b/src/components/panes/map/map-styles.css
@@ -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;
+}
diff --git a/src/components/panes/map/utils.ts b/src/components/panes/map/utils.ts
index d10ed7e..b8fd153 100644
--- a/src/components/panes/map/utils.ts
+++ b/src/components/panes/map/utils.ts
@@ -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;
+ primaryDimensionColor?: string;
[key: string]: unknown;
}
@@ -83,6 +113,7 @@ export function toRFNodes(
selectedNodeId: number | null,
connectedNodeIds: Set,
existingPositions: Map,
+ dimensionIcons?: Record,
): RFNode[] {
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
diff --git a/src/components/views/GridView.tsx b/src/components/views/GridView.tsx
index bfd3433..9f953fa 100644
--- a/src/components/views/GridView.tsx
+++ b/src/components/views/GridView.tsx
@@ -1,7 +1,8 @@
"use client";
import { Node } from '@/types/database';
-import { File } from 'lucide-react';
+import { getNodeIcon } from '@/utils/nodeIcons';
+import { useDimensionIcons } from '@/context/DimensionIconsContext';
interface GridViewProps {
nodes: Node[];
@@ -9,6 +10,7 @@ interface GridViewProps {
}
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
+ const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 120) => {
if (!content) return '';
if (content.length <= maxLength) return content;
@@ -85,7 +87,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
-
+ {getNodeIcon(node, dimensionIcons, 14)}
{
if (!dateString) return '';
const date = new Date(dateString);
@@ -84,7 +86,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
-
+ {getNodeIcon(node, dimensionIcons, 16)}
{/* Content */}
diff --git a/src/components/views/ViewsOverlay.tsx b/src/components/views/ViewsOverlay.tsx
index c73955f..d2488dc 100644
--- a/src/components/views/ViewsOverlay.tsx
+++ b/src/components/views/ViewsOverlay.tsx
@@ -5,6 +5,7 @@ import { Plus, Trash2, LayoutGrid, List, Columns3, Save, Filter, ChevronDown, X
import type { Node } from '@/types/database';
import InputDialog from '../common/InputDialog';
import { getNodeIcon } from '@/utils/nodeIcons';
+import { useDimensionIcons } from '@/context/DimensionIconsContext';
type ViewMode = 'grid' | 'list' | 'kanban';
@@ -39,6 +40,7 @@ interface ViewsOverlayProps {
}
export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) {
+ const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker
const [dimensions, setDimensions] = useState([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true);
@@ -401,7 +403,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
// Render node card
const renderNodeCard = (node: Node, columnId?: string, dimension?: string) => {
- const nodeIcon = getNodeIcon(node);
+ const nodeIcon = getNodeIcon(node, dimensionIcons);
return (
;
+ setDimensionIcons: React.Dispatch
>>;
+}
+
+const DimensionIconsContext = createContext({
+ dimensionIcons: {},
+ setDimensionIcons: () => {},
+});
+
+export function DimensionIconsProvider({ children }: { children: ReactNode }) {
+ const [dimensionIcons, setDimensionIcons] = usePersistentState>('ui.dimensionIcons', {});
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDimensionIcons() {
+ return useContext(DimensionIconsContext);
+}
diff --git a/src/utils/nodeIcons.tsx b/src/utils/nodeIcons.tsx
index 92ccac5..02e2fd8 100644
--- a/src/utils/nodeIcons.tsx
+++ b/src/utils/nodeIcons.tsx
@@ -1,26 +1,28 @@
"use client";
import { useState } from 'react';
-import { Video, FileText, File, Globe } from 'lucide-react';
+import { Video, FileText, File, Globe, Folder } from 'lucide-react';
import { Node } from '@/types/database';
+import { getIconByName } from '@/components/common/LucideIconPicker';
interface FaviconIconProps {
domain: string;
+ size?: number;
}
-const FaviconIcon = ({ domain }: FaviconIconProps) => {
+const FaviconIcon = ({ domain, size = 16 }: FaviconIconProps) => {
const [failed, setFailed] = useState(false);
-
+
if (failed) {
- return ;
+ return ;
}
-
+
return (
// eslint-disable-next-line @next/next/no-img-element
setFailed(true)}
@@ -28,29 +30,73 @@ const FaviconIcon = ({ domain }: FaviconIconProps) => {
);
};
-export function getNodeIcon(node: Node): React.ReactElement {
- // No link - show generic file icon
- if (!node.link) {
- return ;
+/**
+ * Resolve the icon for a node.
+ *
+ * Priority:
+ * 1. URL-derived icon (favicon, YouTube, PDF) — if node has a link
+ * 2. Dimension-derived icon — from the node's most popular dimension that has an icon set
+ * 3. Fallback to generic File icon
+ *
+ * @param node - The database node
+ * @param dimensionIcons - Map of dimension name → Lucide icon name (from DimensionIconsContext)
+ * @param size - Icon size in px (default 16)
+ */
+export function getNodeIcon(
+ node: Node,
+ dimensionIcons?: Record,
+ size: number = 16,
+): React.ReactElement {
+ // If node has a link, use URL-derived icon (primary)
+ if (node.link) {
+ const url = node.link.toLowerCase();
+
+ // YouTube videos
+ if (url.includes('youtube.com') || url.includes('youtu.be')) {
+ return ;
+ }
+
+ // PDFs and papers
+ if (url.endsWith('.pdf') || node.metadata?.type === 'paper') {
+ return ;
+ }
+
+ // Website favicon with graceful fallback
+ try {
+ const domain = new URL(node.link).hostname;
+ return ;
+ } catch {
+ return ;
+ }
}
-
- const url = node.link.toLowerCase();
-
- // YouTube videos
- if (url.includes('youtube.com') || url.includes('youtu.be')) {
- return ;
- }
-
- // PDFs and papers
- if (url.endsWith('.pdf') || node.metadata?.type === 'paper') {
- return ;
- }
-
- // Website favicon with graceful fallback
- try {
- const domain = new URL(node.link).hostname;
- return ;
- } catch {
- return ;
+
+ // No link — try dimension-derived icon
+ if (dimensionIcons && node.dimensions?.length) {
+ // Find the first dimension that has an icon set
+ // (dimensions are already ordered, so first match wins)
+ for (const dim of node.dimensions) {
+ const iconName = dimensionIcons[dim];
+ if (iconName && iconName !== 'Folder') {
+ const IconComponent = getIconByName(iconName);
+ return ;
+ }
+ }
}
+
+ // Fallback
+ return ;
+}
+
+/**
+ * Get the dimension icon for a given dimension name.
+ * Returns Folder if no icon is set.
+ */
+export function getDimensionIcon(
+ dimensionName: string,
+ dimensionIcons: Record,
+ size: number = 16,
+): React.ReactElement {
+ const iconName = dimensionIcons[dimensionName] || 'Folder';
+ const IconComponent = getIconByName(iconName);
+ return ;
}