Skip to content

Commit 39cb939

Browse files
committed
feat(graph-layers): collapsable linear DAG chains (#337)
1 parent 4678e9a commit 39cb939

File tree

12 files changed

+2043
-214
lines changed

12 files changed

+2043
-214
lines changed

.eslintrc.cjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ module.exports = getESLintConfig({
4242
args: 'none'
4343
}
4444
],
45-
'@typescript-eslint/no-empty-function': 0
45+
'@typescript-eslint/no-empty-function': 0,
46+
'@typescript-eslint/no-base-to-string': ['warn']
4647
}
4748
},
4849
{

examples/graph-layers/graph-viewer/app.tsx

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const LAYOUT_FACTORIES: Record<LayoutType, LayoutFactory> = {
4949
'radial-layout': (options) => new RadialLayout(options),
5050
'hive-plot-layout': (options) => new HivePlotLayout(options),
5151
'force-multi-graph-layout': (options) => new ForceMultiGraphLayout(options),
52-
'd3-dag-layout': () => new D3DagLayout(),
52+
'd3-dag-layout': (options) => new D3DagLayout(options),
5353
};
5454

5555

@@ -97,6 +97,10 @@ export const useLoading = (engine) => {
9797
export function App(props) {
9898
const [selectedExample, setSelectedExample] = useState<ExampleDefinition | undefined>(DEFAULT_EXAMPLE);
9999
const [selectedLayout, setSelectedLayout] = useState<LayoutType>(DEFAULT_LAYOUT);
100+
const [collapseEnabled, setCollapseEnabled] = useState(true);
101+
const [dagChainSummary, setDagChainSummary] = useState<
102+
{chainIds: string[]; collapsedIds: string[]}
103+
| null>(null);
100104

101105
const graphData = useMemo(() => selectedExample?.data(), [selectedExample]);
102106
const layoutOptions = useMemo(
@@ -117,6 +121,7 @@ export function App(props) {
117121
}, [selectedLayout, layoutOptions]);
118122
const engine = useMemo(() => (graph && layout ? new GraphEngine({graph, layout}) : null), [graph, layout]);
119123
const isFirstMount = useRef(true);
124+
const dagLayout = layout instanceof D3DagLayout ? (layout as D3DagLayout) : null;
120125

121126
useLayoutEffect(() => {
122127
if (!engine) {
@@ -166,6 +171,95 @@ export function App(props) {
166171
const [{isLoading}, loadingDispatch] = useLoading(engine) as any;
167172

168173
const selectedStyles = selectedExample?.style;
174+
const isDagLayout = selectedLayout === 'd3-dag-layout';
175+
const totalChainCount = dagChainSummary?.chainIds.length ?? 0;
176+
const collapsedChainCount = dagChainSummary?.collapsedIds.length ?? 0;
177+
const collapseAllDisabled =
178+
!collapseEnabled || !dagChainSummary || dagChainSummary.chainIds.length === 0;
179+
const expandAllDisabled =
180+
!collapseEnabled || !dagChainSummary || dagChainSummary.collapsedIds.length === 0;
181+
182+
useEffect(() => {
183+
if (isDagLayout) {
184+
setCollapseEnabled(true);
185+
}
186+
}, [isDagLayout, selectedExample]);
187+
188+
useEffect(() => {
189+
if (!dagLayout) {
190+
return;
191+
}
192+
dagLayout.setPipelineOptions({collapseLinearChains: collapseEnabled});
193+
if (!collapseEnabled) {
194+
dagLayout.setCollapsedChains([]);
195+
}
196+
}, [dagLayout, collapseEnabled]);
197+
198+
useEffect(() => {
199+
if (!engine || !dagLayout) {
200+
setDagChainSummary(isDagLayout ? {chainIds: [], collapsedIds: []} : null);
201+
return;
202+
}
203+
204+
const updateChainSummary = () => {
205+
const chainIds: string[] = [];
206+
const collapsedIds: string[] = [];
207+
208+
for (const node of engine.getNodes()) {
209+
const chainId = node.getPropertyValue('collapsedChainId');
210+
const nodeIds = node.getPropertyValue('collapsedNodeIds');
211+
const representativeId = node.getPropertyValue('collapsedChainRepresentativeId');
212+
const isCollapsed = Boolean(node.getPropertyValue('isCollapsedChain'));
213+
214+
if (
215+
chainId !== null &&
216+
chainId !== undefined &&
217+
Array.isArray(nodeIds) &&
218+
nodeIds.length > 1 &&
219+
representativeId === node.getId()
220+
) {
221+
const chainKey = String(chainId);
222+
chainIds.push(chainKey);
223+
if (isCollapsed) {
224+
collapsedIds.push(chainKey);
225+
}
226+
}
227+
}
228+
229+
setDagChainSummary({chainIds, collapsedIds});
230+
};
231+
232+
updateChainSummary();
233+
234+
const handleLayoutChange = () => updateChainSummary();
235+
const handleLayoutDone = () => updateChainSummary();
236+
237+
engine.addEventListener('onLayoutChange', handleLayoutChange);
238+
engine.addEventListener('onLayoutDone', handleLayoutDone);
239+
240+
return () => {
241+
engine.removeEventListener('onLayoutChange', handleLayoutChange);
242+
engine.removeEventListener('onLayoutDone', handleLayoutDone);
243+
};
244+
}, [engine, dagLayout, isDagLayout]);
245+
246+
const handleToggleCollapseEnabled = useCallback(() => {
247+
setCollapseEnabled((value) => !value);
248+
}, []);
249+
250+
const handleCollapseAll = useCallback(() => {
251+
if (!collapseEnabled || !dagLayout || !dagChainSummary) {
252+
return;
253+
}
254+
dagLayout.setCollapsedChains(dagChainSummary.chainIds);
255+
}, [collapseEnabled, dagLayout, dagChainSummary]);
256+
257+
const handleExpandAll = useCallback(() => {
258+
if (!collapseEnabled || !dagLayout) {
259+
return;
260+
}
261+
dagLayout.setCollapsedChains([]);
262+
}, [collapseEnabled, dagLayout]);
169263

170264
const fitBounds = useCallback(() => {
171265
if (!engine) {
@@ -330,7 +424,88 @@ export function App(props) {
330424
examples={EXAMPLES}
331425
defaultExample={DEFAULT_EXAMPLE}
332426
onExampleChange={handleExampleChange}
333-
/>
427+
>
428+
{isDagLayout ? (
429+
<section style={{marginBottom: '0.5rem', fontSize: '0.875rem', lineHeight: 1.5}}>
430+
<h3 style={{margin: '0 0 0.5rem', fontSize: '0.875rem', fontWeight: 600, color: '#0f172a'}}>
431+
Collapsed chains
432+
</h3>
433+
<p style={{margin: '0 0 0.75rem', color: '#334155'}}>
434+
Linear chains collapse to a single node marked with plus and minus icons. Use these controls to
435+
expand or collapse all chains. Individual chains remain interactive on the canvas.
436+
</p>
437+
<div style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}>
438+
<div
439+
style={{
440+
display: 'flex',
441+
justifyContent: 'space-between',
442+
fontSize: '0.8125rem',
443+
color: '#475569'
444+
}}
445+
>
446+
<span>Status</span>
447+
<span>
448+
{collapsedChainCount} / {totalChainCount} collapsed
449+
</span>
450+
</div>
451+
<div style={{display: 'flex', gap: '0.5rem', flexWrap: 'wrap'}}>
452+
<button
453+
type="button"
454+
onClick={handleToggleCollapseEnabled}
455+
style={{
456+
background: collapseEnabled ? '#4c6ef5' : '#1f2937',
457+
color: '#ffffff',
458+
border: 'none',
459+
borderRadius: '0.375rem',
460+
padding: '0.375rem 0.75rem',
461+
cursor: 'pointer',
462+
fontFamily: 'inherit',
463+
fontSize: '0.8125rem'
464+
}}
465+
>
466+
{collapseEnabled ? 'Disable collapse' : 'Enable collapse'}
467+
</button>
468+
<button
469+
type="button"
470+
onClick={handleCollapseAll}
471+
disabled={collapseAllDisabled}
472+
style={{
473+
background: '#2563eb',
474+
color: '#ffffff',
475+
border: 'none',
476+
borderRadius: '0.375rem',
477+
padding: '0.375rem 0.75rem',
478+
cursor: collapseAllDisabled ? 'not-allowed' : 'pointer',
479+
fontFamily: 'inherit',
480+
fontSize: '0.8125rem',
481+
opacity: collapseAllDisabled ? 0.5 : 1
482+
}}
483+
>
484+
Collapse all
485+
</button>
486+
<button
487+
type="button"
488+
onClick={handleExpandAll}
489+
disabled={expandAllDisabled}
490+
style={{
491+
background: '#16a34a',
492+
color: '#ffffff',
493+
border: 'none',
494+
borderRadius: '0.375rem',
495+
padding: '0.375rem 0.75rem',
496+
cursor: expandAllDisabled ? 'not-allowed' : 'pointer',
497+
fontFamily: 'inherit',
498+
fontSize: '0.8125rem',
499+
opacity: expandAllDisabled ? 0.5 : 1
500+
}}
501+
>
502+
Expand all
503+
</button>
504+
</div>
505+
</div>
506+
</section>
507+
) : null}
508+
</ControlPanel>
334509
</aside>
335510
</div>
336511
);

examples/graph-layers/graph-viewer/control-panel.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Copyright (c) vis.gl contributors
44

55
import React, {useCallback, useEffect, useMemo, useState} from 'react';
6+
import type {ReactNode} from 'react';
67
import type {GraphLayerProps} from '@deck.gl-community/graph-layers';
78

89
export type LayoutType =
@@ -34,6 +35,7 @@ type ControlPanelProps = {
3435
examples: ExampleDefinition[];
3536
defaultExample?: ExampleDefinition;
3637
onExampleChange: (example: ExampleDefinition, layout: LayoutType) => void;
38+
children?: ReactNode;
3739
};
3840

3941
const LAYOUT_LABELS: Record<LayoutType, string> = {
@@ -46,7 +48,7 @@ const LAYOUT_LABELS: Record<LayoutType, string> = {
4648
'd3-dag-layout': 'D3 DAG Layout',
4749
};
4850

49-
export function ControlPanel({examples, defaultExample, onExampleChange}: ControlPanelProps) {
51+
export function ControlPanel({examples, defaultExample, onExampleChange, children}: ControlPanelProps) {
5052
const resolveExampleIndex = useCallback(
5153
(example?: ExampleDefinition) => {
5254
if (!example) {
@@ -67,6 +69,7 @@ export function ControlPanel({examples, defaultExample, onExampleChange}: Contro
6769
const [selectedLayout, setSelectedLayout] = useState<LayoutType | undefined>(
6870
availableLayouts[0]
6971
);
72+
const [isCollapsed, setIsCollapsed] = useState(false);
7073

7174
useEffect(() => {
7275
if (!availableLayouts.length) {
@@ -129,6 +132,10 @@ export function ControlPanel({examples, defaultExample, onExampleChange}: Contro
129132
);
130133
}, [selectedExample]);
131134

135+
const toggleCollapsed = useCallback(() => {
136+
setIsCollapsed((value) => !value);
137+
}, []);
138+
132139
if (!examples.length) {
133140
return null;
134141
}
@@ -212,6 +219,36 @@ export function ControlPanel({examples, defaultExample, onExampleChange}: Contro
212219
<p style={{margin: 0}}>{datasetDescription}</p>
213220
</section>
214221
) : null}
222+
{children ? (
223+
<section
224+
style={{
225+
borderTop: '1px solid #e2e8f0',
226+
paddingTop: '0.75rem',
227+
display: 'flex',
228+
flexDirection: 'column',
229+
gap: '0.75rem'
230+
}}
231+
>
232+
<button
233+
type="button"
234+
onClick={toggleCollapsed}
235+
style={{
236+
alignSelf: 'flex-start',
237+
fontSize: '0.8125rem',
238+
fontWeight: 600,
239+
border: '1px solid #cbd5f5',
240+
background: '#f8fafc',
241+
color: '#0f172a',
242+
borderRadius: '0.5rem',
243+
padding: '0.25rem 0.5rem',
244+
cursor: 'pointer'
245+
}}
246+
>
247+
{isCollapsed ? 'Expand details' : 'Collapse details'}
248+
</button>
249+
{!isCollapsed ? <div>{children}</div> : null}
250+
</section>
251+
) : null}
215252
{layoutDescription ? (
216253
<section style={{fontSize: '0.875rem', lineHeight: 1.5, color: '#334155'}}>
217254
<h3 style={{margin: '0 0 0.25rem', fontSize: '0.875rem', fontWeight: 600, color: '#0f172a'}}>

0 commit comments

Comments
 (0)