Skip to content

Commit a193090

Browse files
Live data display
Refactor CustomerGraphContext and Graph components to support hypernodes and layout modes. Introduced relation labels for filtering pivots, updated GraphSettingsPanel for relation management, and enhanced GraphImpl to handle hypernode visibility. Removed deprecated test graph data and improved type definitions for better clarity.
1 parent 688ab5f commit a193090

21 files changed

Lines changed: 988 additions & 1291 deletions

File tree

packages/app-builder/src/components/Graph/CustomerGraphContext.tsx

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,23 @@
11
import { createSimpleContext } from '@app-builder/utils/create-context';
22
import { type ReactNode, useCallback, useMemo, useState } from 'react';
33
import { type GraphObjectRef } from './graph-keys';
4-
5-
export const GRAPH_ATTRIBUTES = ['ip', 'iban', 'device', 'email'] as const;
6-
export type GraphAttribute = (typeof GRAPH_ATTRIBUTES)[number];
7-
8-
export const GRAPH_ATTRIBUTE_LABELS: Record<GraphAttribute, string> = {
9-
ip: 'IP',
10-
iban: 'IBAN',
11-
device: 'Device',
12-
email: 'Email',
13-
};
14-
15-
/** Pivot `rawType` → the attribute filter governing it. Unlisted pivot types are always shown. */
16-
export const PIVOT_TYPE_ATTRIBUTES: Record<string, GraphAttribute> = {
17-
same_ip: 'ip',
18-
same_iban: 'iban',
19-
same_device: 'device',
20-
same_email: 'email',
21-
};
4+
import { type GraphLayoutMode } from './graph-layout';
225

236
/** Branch sizes a subtree must exceed to collapse into a cluster chip. `0` disables clustering. */
247
export const CLUSTER_THRESHOLD_OPTIONS = [0, 2, 5, 7, 10, 15, 30, 50] as const;
258
export type ClusterThreshold = (typeof CLUSTER_THRESHOLD_OPTIONS)[number];
269
export const DEFAULT_CLUSTER_THRESHOLD: ClusterThreshold = 10;
2710

11+
export const LAYOUT_MODE_OPTIONS = ['rad-dagre', 'balanced', 'radial'] as const satisfies readonly GraphLayoutMode[];
12+
2813
/**
2914
* The node backing the settings panel's detail card. `persons` are the selection's
3015
* connected persons, or the folded members of a cluster.
3116
*/
3217
export type SelectedGraphObject = GraphObjectRef & { persons: GraphObjectRef[] } & (
3318
| { nodeType: 'person' | 'pivot' }
3419
| { nodeType: 'cluster'; nodeCount: number; internalEdgeCount: number }
20+
| { nodeType: 'hypernode'; hypernodeCount: number }
3521
);
3622

3723
/**
@@ -54,10 +40,13 @@ export type CustomerGraphContextValue = {
5440
showCompanies: boolean;
5541
setShowCompanies: (value: boolean) => void;
5642

57-
// Attribute filters (pivots)
58-
attributes: GraphAttribute[];
59-
setAttributes: (value: GraphAttribute[]) => void;
60-
toggleAttribute: (attribute: GraphAttribute) => void;
43+
/** Configured relation labels available for filtering pivots. */
44+
relationLabels: string[];
45+
setRelationLabels: (labels: string[]) => void;
46+
/** Selected relation labels (pivots matching these labels are shown). */
47+
selectedRelationLabels: string[];
48+
setSelectedRelationLabels: (labels: string[]) => void;
49+
toggleRelationLabel: (label: string) => void;
6150

6251
// Display options
6352
showRiskScore: boolean;
@@ -69,6 +58,9 @@ export type CustomerGraphContextValue = {
6958
showEdgeLabels: boolean;
7059
setShowEdgeLabels: (value: boolean) => void;
7160

61+
layoutMode: GraphLayoutMode;
62+
setLayoutMode: (value: GraphLayoutMode) => void;
63+
7264
// Clustering (branch size at which a subtree collapses; `0` disables)
7365
clusterThreshold: ClusterThreshold;
7466
setClusterThreshold: (value: ClusterThreshold) => void;
@@ -123,19 +115,35 @@ export function CustomerGraphProvider({
123115
initialSelectedObject = null,
124116
clusterThreshold: controlledClusterThreshold,
125117
onClusterThresholdChange,
118+
layoutMode: controlledLayoutMode,
119+
onLayoutModeChange,
126120
}: {
127121
children: ReactNode;
128122
initialSelectedObject?: SelectedGraphObject | null;
129123
/** When provided with `onClusterThresholdChange`, survives provider remounts (e.g. graph regenerate). */
130124
clusterThreshold?: ClusterThreshold;
131125
onClusterThresholdChange?: (value: ClusterThreshold) => void;
126+
layoutMode?: GraphLayoutMode;
127+
onLayoutModeChange?: (value: GraphLayoutMode) => void;
132128
}) {
133129
const [showPersons, setShowPersons] = useState(true);
134130
const [showCompanies, setShowCompanies] = useState(true);
135-
const [attributes, setAttributes] = useState<GraphAttribute[]>([...GRAPH_ATTRIBUTES]);
131+
const [relationLabels, setRelationLabels] = useState<string[]>([]);
132+
const [selectedRelationLabels, setSelectedRelationLabels] = useState<string[]>([]);
136133
const [showRiskScore, setShowRiskScore] = useState(false);
137134
const [showTags, setShowTags] = useState(false);
138135
const [showEdgeLabels, setShowEdgeLabels] = useState(false);
136+
const [uncontrolledLayoutMode, setUncontrolledLayoutMode] = useState<GraphLayoutMode>('rad-dagre');
137+
const layoutMode = controlledLayoutMode ?? uncontrolledLayoutMode;
138+
const setLayoutMode = useCallback(
139+
(value: GraphLayoutMode) => {
140+
onLayoutModeChange?.(value);
141+
if (controlledLayoutMode === undefined) {
142+
setUncontrolledLayoutMode(value);
143+
}
144+
},
145+
[controlledLayoutMode, onLayoutModeChange],
146+
);
139147
const [uncontrolledClusterThreshold, setUncontrolledClusterThreshold] =
140148
useState<ClusterThreshold>(DEFAULT_CLUSTER_THRESHOLD);
141149
const clusterThreshold = controlledClusterThreshold ?? uncontrolledClusterThreshold;
@@ -170,8 +178,22 @@ export function CustomerGraphProvider({
170178
setExpandedRootIds((prev) => toggleInSet(prev, rootId));
171179
}, []);
172180

173-
const toggleAttribute = useCallback((attribute: GraphAttribute) => {
174-
setAttributes((prev) => (prev.includes(attribute) ? prev.filter((a) => a !== attribute) : [...prev, attribute]));
181+
const toggleRelationLabel = useCallback((label: string) => {
182+
setSelectedRelationLabels((prev) =>
183+
prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label],
184+
);
185+
}, []);
186+
187+
const syncRelationLabels = useCallback((labels: string[]) => {
188+
const uniqueLabels = [...new Set(labels)];
189+
setRelationLabels(uniqueLabels);
190+
setSelectedRelationLabels((prev) => {
191+
// First load → select all; otherwise keep selection and auto-select newly added labels.
192+
if (prev.length === 0) return uniqueLabels;
193+
const kept = prev.filter((label) => uniqueLabels.includes(label));
194+
const added = uniqueLabels.filter((label) => !prev.includes(label));
195+
return [...kept, ...added];
196+
});
175197
}, []);
176198

177199
const clearCheckedNodes = useCallback(() => {
@@ -200,16 +222,20 @@ export function CustomerGraphProvider({
200222
setShowPersons,
201223
showCompanies,
202224
setShowCompanies,
203-
attributes,
204-
setAttributes,
205-
toggleAttribute,
225+
relationLabels,
226+
setRelationLabels: syncRelationLabels,
227+
selectedRelationLabels,
228+
setSelectedRelationLabels,
229+
toggleRelationLabel,
206230
showRiskScore,
207231
setShowRiskScore,
208232
showTags,
209233
setShowTags,
210234
nodeTagsVisible: showTags || selectionMode,
211235
showEdgeLabels,
212236
setShowEdgeLabels,
237+
layoutMode,
238+
setLayoutMode,
213239
clusterThreshold,
214240
setClusterThreshold,
215241
selectedObject,
@@ -234,11 +260,15 @@ export function CustomerGraphProvider({
234260
[
235261
showPersons,
236262
showCompanies,
237-
attributes,
238-
toggleAttribute,
263+
relationLabels,
264+
syncRelationLabels,
265+
selectedRelationLabels,
266+
toggleRelationLabel,
239267
showRiskScore,
240268
showTags,
241269
showEdgeLabels,
270+
layoutMode,
271+
setLayoutMode,
242272
clusterThreshold,
243273
setClusterThreshold,
244274
selectedObject,

packages/app-builder/src/components/Graph/GraphComponents.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { rootNodeId } from './graph-keys';
2121
import {
2222
type ClusterRfNode,
2323
type GraphRfEdge,
24+
type HypernodeRfNode,
2425
type PersonRfData,
2526
type PersonRfNode,
2627
type PivotRfNode,
@@ -326,6 +327,25 @@ function PivotNode({ id, data }: NodeProps<PivotRfNode>) {
326327
);
327328
}
328329

330+
function HypernodeNode({ id, data }: NodeProps<HypernodeRfNode>) {
331+
const { selectionMode, hoveredNodeId } = useCustomerGraph();
332+
const highlighted = useNodeHighlighted(id);
333+
const isHovered = !selectionMode && hoveredNodeId === id;
334+
335+
return (
336+
<div
337+
className={cn(
338+
'border-grey-border bg-grey-white text-grey-primary relative flex w-fit items-center rounded-full border px-sm py-xs text-xs shadow-sm cursor-pointer transition-opacity duration-200',
339+
isHovered && 'ring-2 ring-grey-primary ring-offset-2',
340+
!highlighted && 'opacity-60',
341+
)}
342+
>
343+
<FourHandles />
344+
<span className="font-medium tabular-nums">{data.count}</span>
345+
</div>
346+
);
347+
}
348+
329349
const EDGE_APPEARANCE = {
330350
link: {
331351
dash: undefined,
@@ -432,6 +452,7 @@ function GraphEdge({
432452
export const graphNodeTypes = {
433453
person: PersonNode,
434454
pivot: PivotNode,
455+
hypernode: HypernodeNode,
435456
cluster: ClusterNode,
436457
};
437458

packages/app-builder/src/components/Graph/GraphImpl.tsx

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AutoLayoutControlButton, useLayoutInitializedNodes } from '@app-builder/components/ReactFlow';
22
import { type DataModel } from '@app-builder/models/data-model';
3+
import { type GraphData } from '@app-builder/models/graph';
34
import {
45
applyEdgeChanges,
56
applyNodeChanges,
@@ -13,13 +14,7 @@ import {
1314
import { reachableNodeIds } from 'ego-graph';
1415
import { useCallback, useEffect, useMemo, useState } from 'react';
1516
import { Icon } from 'ui-icons';
16-
import { type GraphData } from '../../routes/_app/_builder/test-graph/-data';
17-
import {
18-
type CustomerGraphContextValue,
19-
type GraphAttribute,
20-
PIVOT_TYPE_ATTRIBUTES,
21-
useCustomerGraph,
22-
} from './CustomerGraphContext';
17+
import { type CustomerGraphContextValue, useCustomerGraph } from './CustomerGraphContext';
2318
import { createGraphTypeHelpers } from './data-model-map';
2419
import { graphEdgeTypes, graphNodeTypes } from './GraphComponents';
2520
import { type GraphObjectRef, nodeKey, parseNodeKey } from './graph-keys';
@@ -48,7 +43,7 @@ export type GraphImplProps = {
4843
* `0` (default) explores the full reachable graph; `N > 0` stops after N hops.
4944
*/
5045
maxExplorationHops?: number;
51-
/** Layout algorithm for A/B testing on the test-graph page. Defaults to rad1. */
46+
/** Layout algorithm. Defaults to rad-dagre; can also be controlled via CustomerGraphContext. */
5247
layoutMode?: GraphLayoutMode;
5348
};
5449

@@ -59,12 +54,13 @@ function resolveStartKey(nodes: GraphRfNode[], fallback: string): string {
5954

6055
type VisibilityFilters = Pick<
6156
CustomerGraphContextValue,
62-
'showPersons' | 'showCompanies' | 'attributes' | 'hiddenNodeIds'
57+
'showPersons' | 'showCompanies' | 'relationLabels' | 'selectedRelationLabels' | 'hiddenNodeIds'
6358
>;
6459

65-
function attributeAllowsPivot(rawType: string, attributes: GraphAttribute[]): boolean {
66-
const attribute = PIVOT_TYPE_ATTRIBUTES[rawType];
67-
return attribute == null || attributes.includes(attribute);
60+
function relationAllowsPivot(rawType: string, relationLabels: string[], selectedRelationLabels: string[]): boolean {
61+
// Pivots that don't match any configured relation label stay visible.
62+
if (!relationLabels.includes(rawType)) return true;
63+
return selectedRelationLabels.includes(rawType);
6864
}
6965

7066
function isNodeVisible(node: GraphRfNode, filters: VisibilityFilters): boolean {
@@ -79,9 +75,10 @@ function isNodeVisible(node: GraphRfNode, filters: VisibilityFilters): boolean {
7975

8076
if (node.type === 'pivot') {
8177
if (filters.hiddenNodeIds.has(node.id)) return false;
82-
return attributeAllowsPivot(node.data.rawType, filters.attributes);
78+
return relationAllowsPivot(node.data.rawType, filters.relationLabels, filters.selectedRelationLabels);
8379
}
8480

81+
// Hypernodes and clusters are not relation-filtered.
8582
return !filters.hiddenNodeIds.has(node.id);
8683
}
8784

@@ -114,11 +111,12 @@ function sameRefs(a: GraphObjectRef[], b: GraphObjectRef[]): boolean {
114111
);
115112
}
116113

117-
export function GraphImpl({ data, dataModel, maxExplorationHops = 0, layoutMode = 'rad-dagre' }: GraphImplProps) {
114+
export function GraphImpl({ data, dataModel, maxExplorationHops = 0, layoutMode: layoutModeProp }: GraphImplProps) {
118115
const {
119116
showPersons,
120117
showCompanies,
121-
attributes,
118+
relationLabels,
119+
selectedRelationLabels,
122120
showEdgeLabels,
123121
setShowEdgeLabels,
124122
selectedObject,
@@ -130,7 +128,9 @@ export function GraphImpl({ data, dataModel, maxExplorationHops = 0, layoutMode
130128
checkedNodeIds,
131129
setGraphStats,
132130
clusterThreshold,
131+
layoutMode: layoutModeFromContext,
133132
} = useCustomerGraph();
133+
const layoutMode = layoutModeProp ?? layoutModeFromContext;
134134

135135
const typeHelpers = useMemo(() => createGraphTypeHelpers(dataModel), [dataModel]);
136136

@@ -140,8 +140,8 @@ export function GraphImpl({ data, dataModel, maxExplorationHops = 0, layoutMode
140140
);
141141

142142
const typeFilters = useMemo(
143-
() => ({ showPersons, showCompanies, attributes }),
144-
[showPersons, showCompanies, attributes],
143+
() => ({ showPersons, showCompanies, relationLabels, selectedRelationLabels }),
144+
[showPersons, showCompanies, relationLabels, selectedRelationLabels],
145145
);
146146

147147
const visibleGraph = useMemo(
@@ -229,7 +229,7 @@ export function GraphImpl({ data, dataModel, maxExplorationHops = 0, layoutMode
229229
// Keep person/pivot neighbor lists in sync when the graph remounts or filters change
230230
// (e.g. initial selection before the first click).
231231
useEffect(() => {
232-
if (!selectedObject || selectedObject.nodeType === 'cluster') return;
232+
if (!selectedObject || selectedObject.nodeType === 'cluster' || selectedObject.nodeType === 'hypernode') return;
233233

234234
const nodeId = nodeKey(selectedObject.objectType, selectedObject.objectId);
235235
if (!nodes.some((n) => n.id === nodeId)) return;
@@ -265,6 +265,17 @@ export function GraphImpl({ data, dataModel, maxExplorationHops = 0, layoutMode
265265
return;
266266
}
267267

268+
if (node.type === 'hypernode') {
269+
setSelectedObject({
270+
nodeType: 'hypernode',
271+
objectType: node.data.objectType,
272+
objectId: node.data.objectId,
273+
hypernodeCount: node.data.count,
274+
persons: [],
275+
});
276+
return;
277+
}
278+
268279
setSelectedObject({
269280
nodeType: 'pivot',
270281
objectType: node.data.rawType,

0 commit comments

Comments
 (0)