Skip to content

Commit 0487e81

Browse files
committed
feat(web): add graph legend for colors and edge types (task-13)
- Create GraphLegend component displaying: - Entity type colors with canonical hash-based coloring - Entity type names and descriptions (works, authors, sources, etc.) - Edge type meanings (cites, authored, affiliated_with) - Direction indicators for relationships - Legend features: - Toggle button (info icon) positioned in top-right corner - Collapsible panel showing all entity types present in current graph - Dynamic entity type detection from actual graph data - Color-coded legend entries matching graph node colors - Edge type descriptions with directional indicators - Close button to hide legend panel - Integration: - Added to graph page container - Automatically shows entity types present in graph - Positioned in top-right with adjustable positioning option - Shows only when user toggles via info icon button Implementation follows plan specification: - Entity type colors using hash-based generation - Edge type meanings (cites, authored, affiliated) - Relationship direction arrows (→ for directed, — for undirected) - Collapsible legend panel - Position option (top-right, bottom-left)
1 parent 1d754fc commit 0487e81

File tree

2 files changed

+291
-13
lines changed

2 files changed

+291
-13
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/**
2+
* Graph Legend Component
3+
*
4+
* Displays legend for graph visualization:
5+
* - Entity type colors
6+
* - Edge type meanings
7+
* - Relationship direction indicators
8+
*
9+
* @module components/graph/GraphLegend
10+
*/
11+
12+
import type { EntityType } from '@bibgraph/types';
13+
import { ActionIcon, Box, CloseIcon, Flex, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
14+
import { useDisclosure } from '@mantine/hooks';
15+
import { IconInfoCircle } from '@tabler/icons-react';
16+
import { useMemo } from 'react';
17+
18+
import { ICON_SIZE } from '@/config/style-constants';
19+
import { ENTITY_TYPE_COLORS as HASH_BASED_ENTITY_COLORS } from '@/styles/hash-colors';
20+
21+
interface GraphLegendProps {
22+
/** Entity types present in the graph */
23+
entityTypes: EntityType[];
24+
/** Whether to show edge types in legend */
25+
showEdgeTypes?: boolean;
26+
/** Position variant */
27+
position?: 'top-right' | 'bottom-left';
28+
}
29+
30+
/**
31+
* Entity type display names
32+
*/
33+
const ENTITY_TYPE_NAMES: Record<EntityType, string> = {
34+
works: 'Works',
35+
authors: 'Authors',
36+
sources: 'Sources',
37+
publishers: 'Publishers',
38+
institutions: 'Institutions',
39+
funders: 'Funders',
40+
topics: 'Topics',
41+
concepts: 'Concepts',
42+
keywords: 'Keywords',
43+
domains: 'Domains',
44+
fields: 'Fields',
45+
subfields: 'Subfields',
46+
} as const;
47+
48+
/**
49+
* Entity type descriptions
50+
*/
51+
const ENTITY_TYPE_DESCRIPTIONS: Record<EntityType, string> = {
52+
works: 'Publications, datasets, software, and other research outputs',
53+
authors: 'Researchers, authors, and contributors',
54+
sources: 'Venues where works are published (journals, conferences)',
55+
publishers: 'Organizations that publish sources',
56+
institutions: 'Affiliations of authors',
57+
funders: 'Funding agencies and grants',
58+
topics: 'Research topics and themes',
59+
concepts: 'Concepts and tags',
60+
keywords: 'Keywords and phrases',
61+
domains: 'High-level research domains',
62+
fields: 'Specialized research fields',
63+
subfields: 'Specialized subfields within domains',
64+
} as const;
65+
66+
/**
67+
* Edge type descriptions
68+
*/
69+
const EDGE_TYPES = [
70+
{
71+
type: 'cites',
72+
description: 'Citation relationship',
73+
color: '#94a3b8',
74+
icon: '→',
75+
},
76+
{
77+
type: 'authored',
78+
description: 'Authorship relationship',
79+
color: '#60a5fa',
80+
icon: '—',
81+
},
82+
{
83+
type: 'affiliated_with',
84+
description: 'Affiliation relationship',
85+
color: '#34d399',
86+
icon: '⋔',
87+
},
88+
] as const;
89+
90+
/**
91+
* Graph Legend Component
92+
* @param root0
93+
* @param root0.entityTypes
94+
* @param root0.showEdgeTypes
95+
* @param root0.position
96+
*/
97+
export const GraphLegend: React.FC<GraphLegendProps> = ({
98+
entityTypes,
99+
showEdgeTypes = true,
100+
position = 'top-right',
101+
}) => {
102+
const [opened, { toggle }] = useDisclosure(false);
103+
104+
// Get unique entity types and sort alphabetically
105+
const sortedEntityTypes = useMemo(() => {
106+
return [...new Set(entityTypes)].sort();
107+
}, [entityTypes]);
108+
109+
// Position styles
110+
const positionStyles = {
111+
'top-right': {
112+
top: 16,
113+
right: 16,
114+
},
115+
'bottom-left': {
116+
bottom: 16,
117+
left: 16,
118+
},
119+
};
120+
121+
return (
122+
<>
123+
{/* Legend toggle button */}
124+
<Tooltip label={opened ? 'Hide legend' : 'Show legend'}>
125+
<ActionIcon
126+
variant={opened ? 'filled' : 'light'}
127+
onClick={toggle}
128+
aria-label="Toggle graph legend"
129+
style={{
130+
position: 'absolute',
131+
...positionStyles[position],
132+
zIndex: 101,
133+
}}
134+
>
135+
<IconInfoCircle size={ICON_SIZE.MD} />
136+
</ActionIcon>
137+
</Tooltip>
138+
139+
{/* Legend panel */}
140+
{opened && (
141+
<Paper
142+
shadow="sm"
143+
p="md"
144+
withBorder
145+
style={{
146+
position: 'absolute',
147+
top: 60,
148+
right: 16,
149+
width: 280,
150+
maxHeight: 'calc(100vh - 200px)',
151+
overflow: 'auto',
152+
zIndex: 100,
153+
}}
154+
>
155+
<Stack gap="sm">
156+
{/* Header */}
157+
<Flex justify="space-between" align="center">
158+
<Title order={6}>Graph Legend</Title>
159+
<ActionIcon
160+
variant="subtle"
161+
size="sm"
162+
onClick={toggle}
163+
aria-label="Close legend"
164+
>
165+
<CloseIcon size="16" />
166+
</ActionIcon>
167+
</Flex>
168+
169+
{/* Entity types */}
170+
<Box>
171+
<Text size="sm" fw={500} c="dimmed">
172+
Entity Types ({sortedEntityTypes.length})
173+
</Text>
174+
<Stack gap="xs" mt="xs">
175+
{sortedEntityTypes.map((type) => (
176+
<Box key={type}>
177+
<Flex gap="xs" align="center">
178+
<Box
179+
style={{
180+
width: 12,
181+
height: 12,
182+
borderRadius: '50%',
183+
backgroundColor: HASH_BASED_ENTITY_COLORS[type],
184+
flexShrink: 0,
185+
}}
186+
/>
187+
<Text size="sm" fw={500}>
188+
{ENTITY_TYPE_NAMES[type]}
189+
</Text>
190+
</Flex>
191+
{ENTITY_TYPE_DESCRIPTIONS[type] && (
192+
<Text size="xs" c="dimmed" ml="xl">
193+
{ENTITY_TYPE_DESCRIPTIONS[type]}
194+
</Text>
195+
)}
196+
</Box>
197+
))}
198+
</Stack>
199+
</Box>
200+
201+
{/* Edge types */}
202+
{showEdgeTypes && (
203+
<>
204+
<Box>
205+
<Text size="sm" fw={500} c="dimmed">
206+
Edge Types
207+
</Text>
208+
<Stack gap="xs" mt="xs">
209+
{EDGE_TYPES.map((edgeType) => (
210+
<Box key={edgeType.type}>
211+
<Flex gap="xs" align="center">
212+
<Text
213+
size="sm"
214+
style={{
215+
color: edgeType.color,
216+
fontFamily: 'monospace',
217+
fontWeight: 700,
218+
}}
219+
>
220+
{edgeType.icon}
221+
</Text>
222+
<Text size="sm" fw={500}>
223+
{edgeType.type}
224+
</Text>
225+
</Flex>
226+
<Text size="xs" c="dimmed" ml="xl">
227+
{edgeType.description}
228+
</Text>
229+
</Box>
230+
))}
231+
</Stack>
232+
</Box>
233+
234+
{/* Direction indicator */}
235+
<Box>
236+
<Text size="sm" fw={500} c="dimmed">
237+
Direction
238+
</Text>
239+
<Stack gap="xs" mt="xs">
240+
<Flex gap="xs" align="center">
241+
<Text
242+
size="sm"
243+
style={{
244+
color: '#94a3b8',
245+
fontFamily: 'monospace',
246+
fontWeight: 700,
247+
}}
248+
>
249+
250+
</Text>
251+
<Text size="xs" c="dimmed">
252+
Directional relationship
253+
</Text>
254+
</Flex>
255+
<Flex gap="xs" align="center">
256+
<Text
257+
size="sm"
258+
style={{
259+
color: '#94a3b8',
260+
fontFamily: 'monospace',
261+
fontWeight: 700,
262+
}}
263+
>
264+
265+
</Text>
266+
<Text size="xs" c="dimmed">
267+
Undirected relationship
268+
</Text>
269+
</Flex>
270+
</Stack>
271+
</Box>
272+
</>
273+
)}
274+
</Stack>
275+
</Paper>
276+
)}
277+
</>
278+
);
279+
};

apps/web/src/routes/graph.lazy.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* @module routes/graph
1414
*/
1515

16+
import type { GraphNode } from '@bibgraph/types';
1617
import {
1718
ActionIcon,
1819
Alert,
@@ -50,8 +51,8 @@ import { type ForceGraphMethods } from 'react-force-graph-2d';
5051
import { ForceGraph3DVisualization } from '@/components/graph/3d/ForceGraph3DVisualization';
5152
import { GraphAnnotations } from '@/components/graph/annotations';
5253
import { GraphEmptyState } from '@/components/graph/GraphEmptyState';
54+
import { GraphLegend } from '@/components/graph/GraphLegend';
5355
import { GraphMiniMap } from '@/components/graph/GraphMiniMap';
54-
import { GraphSnapshots } from '@/components/graph/snapshots';
5556
import { GraphSourcePanel } from '@/components/graph/GraphSourcePanel';
5657
import { LayoutSelector } from '@/components/graph/LayoutSelector';
5758
import {
@@ -60,14 +61,13 @@ import {
6061
NodeContextMenu,
6162
} from '@/components/graph/NodeContextMenu';
6263
import { OptimizedForceGraphVisualization } from '@/components/graph/OptimizedForceGraphVisualization';
64+
import { GraphSnapshots } from '@/components/graph/snapshots';
6365
import type { DisplayMode } from '@/components/graph/types';
6466
import { ViewModeToggle } from '@/components/ui/ViewModeToggle';
6567
import { ICON_SIZE, LAYOUT } from '@/config/style-constants';
66-
import type { GraphNode } from '@bibgraph/types';
6768
import { useGraphVisualizationContext } from '@/contexts/GraphVisualizationContext';
6869
import { type GraphMethods, useFitToView } from '@/hooks/useFitToView';
6970
import { useGraphAnnotations } from '@/hooks/useGraphAnnotations';
70-
import { useGraphSnapshots } from '@/hooks/useGraphSnapshots';
7171
import { type GraphLayoutType,useGraphLayout } from '@/hooks/useGraphLayout';
7272
import { useNodeExpansion } from '@/lib/graph-index';
7373

@@ -148,11 +148,6 @@ const EntityGraphPage = () => {
148148
// Mini-map state
149149
const [cameraPosition, setCameraPosition] = useState({ zoom: 1, panX: 0, panY: 0 });
150150

151-
// Snapshots state (for loading snapshots)
152-
const [snapshotEdges, setSnapshotEdges] = useState<string>(JSON.stringify(edges));
153-
const [snapshotLayout, setSnapshotLayout] = useState<GraphLayoutType>('force');
154-
const [snapshotNodePositions, setSnapshotNodePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
155-
156151
// Fit-to-view operations (shared logic for 2D/3D)
157152
const { fitToViewAll, fitToViewSelected } = useFitToView({
158153
graphMethodsRef,
@@ -263,13 +258,10 @@ const EntityGraphPage = () => {
263258
nodePositions?: Map<string, { x: number; y: number }>;
264259
annotations?: unknown[];
265260
}) => {
266-
// Update state with snapshot data
267-
setSnapshotEdges(snapshot.edges);
268-
setSnapshotLayout(snapshot.layoutType);
269-
setSnapshotNodePositions(snapshot.nodePositions ?? new Map());
261+
// Update camera position from snapshot
270262
setCameraPosition({ zoom: snapshot.zoom, panX: snapshot.panX, panY: snapshot.panY });
271263

272-
// TODO: Update nodes and annotations in context
264+
// TODO: Update nodes, edges, layout, and annotations in context
273265
// This requires extending the GraphVisualizationContext to support full state replacement
274266
}, []);
275267

@@ -659,6 +651,13 @@ const EntityGraphPage = () => {
659651
onPan={handleMiniMapPan}
660652
/>
661653
)}
654+
655+
{/* Graph legend */}
656+
<GraphLegend
657+
entityTypes={nodes.map(n => n.entityType)}
658+
showEdgeTypes={true}
659+
position="top-right"
660+
/>
662661
</Box>
663662
</Stack>
664663
</Card>

0 commit comments

Comments
 (0)