diff --git a/packages/angular/src/components/graph/graph.component.ts b/packages/angular/src/components/graph/graph.component.ts index eff3b1298..d5d3714fc 100644 --- a/packages/angular/src/components/graph/graph.component.ts +++ b/packages/angular/src/components/graph/graph.component.ts @@ -29,7 +29,7 @@ import { } from '@unovis/ts' import { Selection } from 'd3-selection' import { D3DragEvent } from 'd3-drag' -import { D3ZoomEvent } from 'd3-zoom' +import { D3ZoomEvent, ZoomTransform } from 'd3-zoom' import { D3BrushEvent } from 'd3-brush' import { VisCoreComponent } from '../../core' @@ -310,9 +310,20 @@ export class VisGraphComponent, nodeGroupElementSelection: Selection, null, unknown>, config: GraphConfigInterface, duration: number, zoomLevel: number) => void + /** Custom partial "update" function for node rendering which will be triggered after the following events: + * - Full node update (`nodeUpdateCustomRenderFunction`); + * - Background click; + * - Node and Link mouseover and mouseout; + * - Node brushing, + * Default: `undefined` */ + @Input() nodePartialUpdateCustomRenderFunction?: (datum: GraphNode, nodeGroupElementSelection: Selection, null, unknown>, config: GraphConfigInterface, duration: number, zoomLevel: number) => void + /** Custom "exit" function for node rendering. Default: `undefined` */ @Input() nodeExitCustomRenderFunction?: (datum: GraphNode, nodeGroupElementSelection: Selection, null, unknown>, config: GraphConfigInterface, duration: number, zoomLevel: number) => void + /** Custom render function that will be called while zooming / panning the graph. Default: `undefined` */ + @Input() nodeOnZoomCustomRenderFunction?: (datum: GraphNode, nodeGroupElementSelection: Selection, null, unknown>, config: GraphConfigInterface, zoomLevel: number) => void + /** Define the mode for highlighting selected nodes in the graph. Default: `GraphNodeSelectionHighlightMode.GreyoutNonConnected` */ @Input() nodeSelectionHighlightMode?: GraphNodeSelectionHighlightMode @@ -335,7 +346,13 @@ export class VisGraphComponent, event: D3DragEvent, unknown>) => void | undefined /** Zoom event callback. Default: `undefined` */ - @Input() onZoom?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined) => void + @Input() onZoom?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void + + /** Zoom start event callback. Default: `undefined` */ + @Input() onZoomStart?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void + + /** Zoom end event callback. Default: `undefined` */ + @Input() onZoomEnd?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void /** Callback function to be called when the graph layout is calculated. Default: `undefined` */ @Input() onLayoutCalculated?: (n: GraphNode[], links: GraphLink[]) => void @@ -369,8 +386,8 @@ export class VisGraphComponent { - const { duration, events, attributes, zoomScaleExtent, disableZoom, zoomEventFilter, disableDrag, disableBrush, zoomThrottledUpdateNodeThreshold, layoutType, layoutAutofit, layoutAutofitTolerance, layoutNonConnectedAside, layoutNodeGroup, layoutGroupOrder, layoutParallelNodesPerColumn, layoutParallelNodeSubGroup, layoutParallelSubGroupsPerRow, layoutParallelGroupSpacing, layoutParallelSortConnectionsByGroup, forceLayoutSettings, dagreLayoutSettings, layoutElkSettings, layoutElkNodeGroups, linkWidth, linkStyle, linkBandWidth, linkArrow, linkStroke, linkDisabled, linkFlow, linkFlowAnimDuration, linkFlowParticleSize, linkLabel, linkLabelShiftFromCenter, linkNeighborSpacing, linkCurvature, selectedLinkId, nodeSize, nodeStrokeWidth, nodeShape, nodeGaugeValue, nodeGaugeFill, nodeGaugeAnimDuration, nodeIcon, nodeIconSize, nodeLabel, nodeLabelTrim, nodeLabelTrimMode, nodeLabelTrimLength, nodeSubLabel, nodeSubLabelTrim, nodeSubLabelTrimMode, nodeSubLabelTrimLength, nodeSideLabels, nodeBottomIcon, nodeDisabled, nodeFill, nodeStroke, nodeSort, nodeEnterPosition, nodeEnterScale, nodeExitPosition, nodeExitScale, nodeEnterCustomRenderFunction, nodeUpdateCustomRenderFunction, nodeExitCustomRenderFunction, nodeSelectionHighlightMode, selectedNodeId, selectedNodeIds, panels, onNodeDragStart, onNodeDrag, onNodeDragEnd, onZoom, onLayoutCalculated, onNodeSelectionBrush, onNodeSelectionDrag, onRenderComplete } = this - const config = { duration, events, attributes, zoomScaleExtent, disableZoom, zoomEventFilter, disableDrag, disableBrush, zoomThrottledUpdateNodeThreshold, layoutType, layoutAutofit, layoutAutofitTolerance, layoutNonConnectedAside, layoutNodeGroup, layoutGroupOrder, layoutParallelNodesPerColumn, layoutParallelNodeSubGroup, layoutParallelSubGroupsPerRow, layoutParallelGroupSpacing, layoutParallelSortConnectionsByGroup, forceLayoutSettings, dagreLayoutSettings, layoutElkSettings, layoutElkNodeGroups, linkWidth, linkStyle, linkBandWidth, linkArrow, linkStroke, linkDisabled, linkFlow, linkFlowAnimDuration, linkFlowParticleSize, linkLabel, linkLabelShiftFromCenter, linkNeighborSpacing, linkCurvature, selectedLinkId, nodeSize, nodeStrokeWidth, nodeShape, nodeGaugeValue, nodeGaugeFill, nodeGaugeAnimDuration, nodeIcon, nodeIconSize, nodeLabel, nodeLabelTrim, nodeLabelTrimMode, nodeLabelTrimLength, nodeSubLabel, nodeSubLabelTrim, nodeSubLabelTrimMode, nodeSubLabelTrimLength, nodeSideLabels, nodeBottomIcon, nodeDisabled, nodeFill, nodeStroke, nodeSort, nodeEnterPosition, nodeEnterScale, nodeExitPosition, nodeExitScale, nodeEnterCustomRenderFunction, nodeUpdateCustomRenderFunction, nodeExitCustomRenderFunction, nodeSelectionHighlightMode, selectedNodeId, selectedNodeIds, panels, onNodeDragStart, onNodeDrag, onNodeDragEnd, onZoom, onLayoutCalculated, onNodeSelectionBrush, onNodeSelectionDrag, onRenderComplete } + const { duration, events, attributes, zoomScaleExtent, disableZoom, zoomEventFilter, disableDrag, disableBrush, zoomThrottledUpdateNodeThreshold, layoutType, layoutAutofit, layoutAutofitTolerance, layoutNonConnectedAside, layoutNodeGroup, layoutGroupOrder, layoutParallelNodesPerColumn, layoutParallelNodeSubGroup, layoutParallelSubGroupsPerRow, layoutParallelGroupSpacing, layoutParallelSortConnectionsByGroup, forceLayoutSettings, dagreLayoutSettings, layoutElkSettings, layoutElkNodeGroups, linkWidth, linkStyle, linkBandWidth, linkArrow, linkStroke, linkDisabled, linkFlow, linkFlowAnimDuration, linkFlowParticleSize, linkLabel, linkLabelShiftFromCenter, linkNeighborSpacing, linkCurvature, selectedLinkId, nodeSize, nodeStrokeWidth, nodeShape, nodeGaugeValue, nodeGaugeFill, nodeGaugeAnimDuration, nodeIcon, nodeIconSize, nodeLabel, nodeLabelTrim, nodeLabelTrimMode, nodeLabelTrimLength, nodeSubLabel, nodeSubLabelTrim, nodeSubLabelTrimMode, nodeSubLabelTrimLength, nodeSideLabels, nodeBottomIcon, nodeDisabled, nodeFill, nodeStroke, nodeSort, nodeEnterPosition, nodeEnterScale, nodeExitPosition, nodeExitScale, nodeEnterCustomRenderFunction, nodeUpdateCustomRenderFunction, nodePartialUpdateCustomRenderFunction, nodeExitCustomRenderFunction, nodeOnZoomCustomRenderFunction, nodeSelectionHighlightMode, selectedNodeId, selectedNodeIds, panels, onNodeDragStart, onNodeDrag, onNodeDragEnd, onZoom, onZoomStart, onZoomEnd, onLayoutCalculated, onNodeSelectionBrush, onNodeSelectionDrag, onRenderComplete } = this + const config = { duration, events, attributes, zoomScaleExtent, disableZoom, zoomEventFilter, disableDrag, disableBrush, zoomThrottledUpdateNodeThreshold, layoutType, layoutAutofit, layoutAutofitTolerance, layoutNonConnectedAside, layoutNodeGroup, layoutGroupOrder, layoutParallelNodesPerColumn, layoutParallelNodeSubGroup, layoutParallelSubGroupsPerRow, layoutParallelGroupSpacing, layoutParallelSortConnectionsByGroup, forceLayoutSettings, dagreLayoutSettings, layoutElkSettings, layoutElkNodeGroups, linkWidth, linkStyle, linkBandWidth, linkArrow, linkStroke, linkDisabled, linkFlow, linkFlowAnimDuration, linkFlowParticleSize, linkLabel, linkLabelShiftFromCenter, linkNeighborSpacing, linkCurvature, selectedLinkId, nodeSize, nodeStrokeWidth, nodeShape, nodeGaugeValue, nodeGaugeFill, nodeGaugeAnimDuration, nodeIcon, nodeIconSize, nodeLabel, nodeLabelTrim, nodeLabelTrimMode, nodeLabelTrimLength, nodeSubLabel, nodeSubLabelTrim, nodeSubLabelTrimMode, nodeSubLabelTrimLength, nodeSideLabels, nodeBottomIcon, nodeDisabled, nodeFill, nodeStroke, nodeSort, nodeEnterPosition, nodeEnterScale, nodeExitPosition, nodeExitScale, nodeEnterCustomRenderFunction, nodeUpdateCustomRenderFunction, nodePartialUpdateCustomRenderFunction, nodeExitCustomRenderFunction, nodeOnZoomCustomRenderFunction, nodeSelectionHighlightMode, selectedNodeId, selectedNodeIds, panels, onNodeDragStart, onNodeDrag, onNodeDragEnd, onZoom, onZoomStart, onZoomEnd, onLayoutCalculated, onNodeSelectionBrush, onNodeSelectionDrag, onRenderComplete } const keys = Object.keys(config) as (keyof GraphConfigInterface)[] keys.forEach(key => { if (config[key] === undefined) delete config[key] }) diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/component.tsx b/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/component.tsx index eba21d0a2..1b88d86b6 100644 --- a/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/component.tsx +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/component.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useRef, ReactElement, useState } from 'rea import { Selection } from 'd3-selection' import { cx } from '@emotion/css' import { GraphNode, GraphLink, GraphConfigInterface, Graph, GraphNodeSelectionHighlightMode } from '@unovis/ts' -import { VisSingleContainer, VisGraph, VisSingleContainerProps, VisGraphProps } from '@unovis/react' +import { VisSingleContainer, VisGraph, VisSingleContainerProps, VisGraphProps, VisGraphRef } from '@unovis/react' import { nodeEnterCustomRenderFunction, nodeSvgDefs, nodeUpdateCustomRenderFunction } from './node-rendering' import { DEFAULT_NODE_SIZE, nodeTypeColorMap, nodeTypeIconMap } from './constants' import type { CustomGraphNodeType } from './enums' @@ -15,18 +15,21 @@ import { renderSwimlanes, updateSwimlanes } from './swimlane-rendering' export type CustomGraphProps< N extends CustomGraphNode, L extends CustomGraphLink, -> = VisSingleContainerProps<{ links: L; nodes: N}> & VisGraphProps & { +> = VisSingleContainerProps<{ links: L; nodes: N }> & +VisGraphProps & { links: L[]; nodes: N[]; onBackgroundClick?: (event: MouseEvent) => void; onLinkClick?: (link: L, event: MouseEvent, i: number) => void; onNodeClick?: (node: N, event: MouseEvent, i: number) => void; -}; +} -export const CustomGraph = ( - props: CustomGraphProps -): ReactElement => { - const [showLinkFlow, setShowLinkFlow] = useState(true) +// eslint-disable-next-line @typescript-eslint/naming-convention +function CustomGraphComponent ( + props: CustomGraphProps, + ref: React.Ref | null> +): ReactElement { + const graphRef = useRef>(null) const [selectedNodeId, setSelectedNodeId] = useState(undefined) const graphD3SelectionRef = useRef | null>(null) @@ -38,22 +41,28 @@ export const CustomGraph = ({ - nodes: props.nodes, - links: props.links, - }), [props.nodes, props.links]) + const data = useMemo( + () => ({ + nodes: props.nodes, + links: props.links, + }), + [props.nodes, props.links] + ) - const onRenderComplete = useCallback(( - g: Selection, - nodes: GraphNode[], - links: GraphLink[], - config: GraphConfigInterface, - duration: number, - zoomLevel: number - ): void => { - graphD3SelectionRef.current = g - renderSwimlanes(g, nodes) - }, []) + const onRenderComplete = useCallback( + ( + g: Selection, + nodes: GraphNode[], + links: GraphLink[], + config: GraphConfigInterface, + duration: number, + zoomLevel: number + ): void => { + graphD3SelectionRef.current = g + renderSwimlanes(g, nodes) + }, + [] + ) const onZoom = useCallback((zoomLevel: number) => { if (graphD3SelectionRef.current) { @@ -61,61 +70,63 @@ export const CustomGraph = ({ - [Graph.selectors.node]: { - click: (n: N) => { setSelectedNodeId(n.id) }, - }, - [Graph.selectors.background]: { - click: () => { setSelectedNodeId(undefined) }, - }, - }), [setSelectedNodeId]) + const events = useMemo( + () => ({ + [Graph.selectors.node]: { + click: (n: N) => { + setSelectedNodeId(n.id) + }, + }, + [Graph.selectors.background]: { + click: () => { + setSelectedNodeId(undefined) + }, + }, + }), + [setSelectedNodeId] + ) + + React.useImperativeHandle(ref, () => graphRef.current) return ( - <> - - - layoutType={'parallel'} - layoutNodeGroup={useCallback((n: N) => n.type, [])} - linkArrow={useCallback((l: L) => l.showArrow, [])} - linkBandWidth={useCallback((l: L) => l.bandWidth, [])} - linkCurvature={1} - linkFlow={useCallback((l: L) => showLinkFlow && l.showFlow, [showLinkFlow])} - linkWidth={useCallback((l: L) => l.width, [])} - nodeFill={getNodeFillColor} - nodeIcon={getNodeIcon} - nodeSize={DEFAULT_NODE_SIZE} - nodeIconSize={DEFAULT_NODE_SIZE} - nodeLabel={useCallback((n: N) => n.label, [])} - nodeLabelTrimLength={30} - nodeStroke={'none'} - nodeSubLabel={useCallback((n: N) => n.subLabel, [])} - nodeSubLabelTrimLength={30} - nodeEnterCustomRenderFunction={nodeEnterCustomRenderFunction} - nodeUpdateCustomRenderFunction={nodeUpdateCustomRenderFunction} - onRenderComplete={onRenderComplete} - nodeSelectionHighlightMode={GraphNodeSelectionHighlightMode.None} - onZoom={onZoom} - selectedNodeId={selectedNodeId} - events={events} - {...props} - /> - -
- -
- + + + ref={graphRef} + layoutType={'parallel'} + layoutNodeGroup={useCallback((n: N) => n.type, [])} + linkArrow={useCallback((l: L) => l.showArrow, [])} + linkBandWidth={useCallback((l: L) => l.bandWidth, [])} + linkCurvature={1} + linkWidth={useCallback((l: L) => l.width, [])} + nodeFill={getNodeFillColor} + nodeIcon={getNodeIcon} + nodeSize={DEFAULT_NODE_SIZE} + nodeIconSize={DEFAULT_NODE_SIZE} + nodeLabel={useCallback((n: N) => n.label, [])} + nodeLabelTrimLength={30} + nodeStroke={'none'} + nodeSubLabel={useCallback((n: N) => n.subLabel, [])} + nodeSubLabelTrimLength={30} + nodeEnterCustomRenderFunction={nodeEnterCustomRenderFunction} + nodeUpdateCustomRenderFunction={nodeUpdateCustomRenderFunction} + onRenderComplete={onRenderComplete} + nodeSelectionHighlightMode={GraphNodeSelectionHighlightMode.None} + onZoom={onZoom} + selectedNodeId={selectedNodeId} + events={events} + zoomScaleExtent={useMemo(() => [0.5, 3], [])} + {...props} + /> + ) } + +export const CustomGraph = React.forwardRef(CustomGraphComponent) as ( + props: CustomGraphProps & { ref?: React.Ref> } +) => ReactElement diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/index.tsx b/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/index.tsx index 7dd48934f..f9a284780 100644 --- a/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/index.tsx +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/index.tsx @@ -1,13 +1,20 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import type { GraphLink, GraphNode } from '@unovis/ts' +import type { VisGraphRef } from '@unovis/react' + import { CustomGraph } from './component' import { CustomGraphNodeType } from './enums' import type { CustomGraphLink, CustomGraphNode } from './types' +import * as s from './styles' export const title = 'Graph: Custom Nodes' export const subTitle = 'User provided rendering functions' export const component = (): JSX.Element => { + const [showLinkFlow, setShowLinkFlow] = useState(true) + const graphRef = useRef | null>(null) + const nodes: CustomGraphNode[] = useMemo(() => ([ { id: '0', @@ -35,8 +42,40 @@ export const component = (): JSX.Element => { { source: '1', target: '5', showFlow: true }, ]), []) + // Modifying layout after the calculation + const onLayoutCalculated = useCallback((nodes: GraphNode[], links: GraphLink[]) => { + nodes[0].x = 100 + }, []) + + + const fitView = useCallback((nodeIds?: string[]) => { + graphRef.current?.component?.fitView(undefined, nodeIds) + }, []) + return ( - + <> + showLinkFlow && l.showFlow, [showLinkFlow])} + onLayoutCalculated={onLayoutCalculated} + + /> +
+ + + +
+ ) } diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/styles.ts b/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/styles.ts index 4feaa4206..62e7adfe6 100644 --- a/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/styles.ts +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-custom-node-rendering/styles.ts @@ -131,3 +131,9 @@ export const checkboxContainer = css` border-radius: 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); ` + +export const graphButton = css` + label: graph-button; + display: block; + margin-top: 5px; +` diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/bucket.svg b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/bucket.svg new file mode 100644 index 000000000..d2d024def --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/bucket.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/index.tsx b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/index.tsx new file mode 100644 index 000000000..c3d7fb573 --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/index.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react' +import { VisSingleContainer, VisGraph } from '@unovis/react' +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { sample } from '@src/utils/array' + +import personIcon from './person.svg?raw' +import roleIcon from './role.svg?raw' +import instanceIcon from './instance.svg?raw' +import bucketIcon from './bucket.svg?raw' + +export const title = 'Graph: Link Label Icons' +export const subTitle = 'SVG icons in link labels' + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const svgDefs = ` + ${personIcon} + ${roleIcon} + ${instanceIcon} + ${bucketIcon} + ` + + const nodes = [ + { id: 'jdoe@acme.com', icon: '#personIcon', fillColor: '#DFFAFD', label: 'External User', sublabel: 'jdoe@acme.com' }, + { id: 'AWSReservedSSO_Something', icon: '#roleIcon', fillColor: '#E3DEFC', label: 'Role', sublabel: 'AWSReservedSSO_Something' }, + ] + + const links = [ + { source: 0, target: 1, label: { text: '~' } }, + ] + + const [data, setData] = useState({ nodes, links }) + + // Re-render the component here to test how the link label updates + useEffect(() => { + const interval = setInterval(() => { + const links = [ + { source: 0, target: 1, label: { text: sample(['#personIcon', '#roleIcon', '#instanceIcon', '#bucketIcon', '2', 'long label']) } }, + ] + + setData({ nodes, links }) + }, 1000) + + return () => clearInterval(interval) + }, []) + + return ( + + + data={data} + nodeIcon={(n) => n.icon} + nodeIconSize={18} + nodeStroke={'none'} + nodeFill={n => n.fillColor} + nodeLabel={n => n.label} + nodeSubLabel={n => n.sublabel} + layoutType='dagre' + dagreLayoutSettings={{ + rankdir: 'LR', + ranksep: 120, + nodesep: 20, + }} + linkArrow={'single'} + linkLabel={(l: typeof links[0]) => l.label} + duration={props.duration} + /> + + ) +} + diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/instance.svg b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/instance.svg new file mode 100644 index 000000000..7dfa6895d --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/instance.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/person.svg b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/person.svg new file mode 100644 index 000000000..0b2c3d93d --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/person.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/role.svg b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/role.svg new file mode 100644 index 000000000..90dbf6051 --- /dev/null +++ b/packages/dev/src/examples/networks-and-flows/graph/graph-link-label-icons/role.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ts/src/components/graph/config.ts b/packages/ts/src/components/graph/config.ts index 85566c31b..2e04c9728 100644 --- a/packages/ts/src/components/graph/config.ts +++ b/packages/ts/src/components/graph/config.ts @@ -240,8 +240,12 @@ export interface GraphConfigInterface, event: D3DragEvent, unknown>) => void | undefined; /** Zoom event callback. Default: `undefined` */ onZoom?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void; + /** Zoom start event callback. Default: `undefined` */ + onZoomStart?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void; + /** Zoom end event callback. Default: `undefined` */ + onZoomEnd?: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void; /** Callback function to be called when the graph layout is calculated. Default: `undefined` */ - onLayoutCalculated?: (n: GraphNode[], links: GraphLink[]) => void; + onLayoutCalculated?: (nodes: GraphNode[], links: GraphLink[]) => void; /** Graph node selection brush callback function. Default: `undefined` */ onNodeSelectionBrush?: (selectedNodes: GraphNode[], event: D3BrushEvent | undefined) => void; /** Graph multiple node drag callback function. Default: `undefined` */ @@ -351,6 +355,8 @@ export const GraphDefaultConfig: GraphConfigInterface() .scaleExtent(this.config.zoomScaleExtent) .on('zoom', (e: D3ZoomEvent) => this._onZoom(e.transform, e)) + .on('start', (e: D3ZoomEvent) => this._onZoomStart(e.transform, e)) + .on('end', (e: D3ZoomEvent) => this._onZoomEnd(e.transform, e)) this._brushBehavior = brush() .on('start brush end', this._onBrush.bind(this)) @@ -461,9 +463,10 @@ export class Graph< } } - private _fit (duration = 0): void { + private _fit (duration = 0, nodeIds?: (string | number)[]): void { const { datamodel: { nodes } } = this - const transform = this._getTransform(nodes) + const fitViewNodes = nodeIds?.length ? nodes.filter(n => nodeIds.includes(n.id)) : nodes + const transform = this._getTransform(fitViewNodes) smartTransition(this.g, duration) .call(this._zoomBehavior.transform, transform) this._onZoom(transform) @@ -707,6 +710,20 @@ export class Graph< ) } + private _onZoomStart (t: ZoomTransform, event?: D3ZoomEvent): void { + const { config } = this + const transform = t || event.transform + this._scale = transform.k + if (isFunction(config.onZoomStart)) config.onZoomStart(this._scale, config.zoomScaleExtent, event, transform) + } + + private _onZoomEnd (t: ZoomTransform, event?: D3ZoomEvent): void { + const { config } = this + const transform = t || event.transform + this._scale = transform.k + if (isFunction(config.onZoomEnd)) config.onZoomEnd(this._scale, config.zoomScaleExtent, event, transform) + } + private _updateNodePosition (d: GraphNode, x: number, y: number): void { const transform = zoomTransform(this.g.node()) const scale = transform.k @@ -969,9 +986,9 @@ export class Graph< return zoomTransform(this.g.node()).k } - public fitView (duration = this.config.duration): void { - this._layoutCalculationPromise.then(() => { - this._fit(duration) + public fitView (duration = this.config.duration, nodeIds?: (string | number)[]): void { + this._layoutCalculationPromise?.then(() => { + this._fit(duration, nodeIds) }) } diff --git a/packages/ts/src/components/graph/modules/link/index.ts b/packages/ts/src/components/graph/modules/link/index.ts index 08a1575a8..1fbf8dc33 100644 --- a/packages/ts/src/components/graph/modules/link/index.ts +++ b/packages/ts/src/components/graph/modules/link/index.ts @@ -19,7 +19,7 @@ import { GraphCircleLabel, GraphLink, GraphLinkArrowStyle, GraphLinkStyle } from import { GraphConfigInterface } from '../../config' // Helpers -import { getX, getY } from '../node/helper' +import { getX, getY, isInternalHref } from '../node/helper' import { getLinkShiftTransform, getLinkStrokeWidth, @@ -66,9 +66,6 @@ export function createLinks linkLabelGroup.append('rect') .attr('class', linkSelectors.linkLabelBackground) - - linkLabelGroup.append('text') - .attr('class', linkSelectors.linkLabelContent) } /** Updates the links partially according to their `_state` */ @@ -206,13 +203,38 @@ export function updateLinks const linkLabelPos = linkPathElement.getPointAtLength(pathLength / 2 + linkLabelShift) const linkLabelTranslate = `translate(${linkLabelPos.x}, ${linkLabelPos.y})` const linkLabelBackground = linkLabelGroup.select(`.${linkSelectors.linkLabelBackground}`) - const linkLabelContent = linkLabelGroup.select(`.${linkSelectors.linkLabelContent}`) + let linkLabelContent = linkLabelGroup.select(`.${linkSelectors.linkLabelContent}`) // If the label was hidden or didn't have text before, we need to set the initial position - if (!linkLabelContent.text() || linkLabelContent.attr('hidden')) { + if (!linkLabelContent.size() || !linkLabelContent.text() || linkLabelContent.attr('hidden')) { linkLabelGroup.attr('transform', linkLabelTranslate) } + // Update the label content DOM element (text vs use) + const shouldRenderUseElement = isInternalHref(linkLabelText) + linkLabelGroup.select(`.${linkSelectors.linkLabelContent}`).remove() + linkLabelContent = linkLabelGroup + .append(shouldRenderUseElement ? 'use' : 'text') + .attr('class', linkSelectors.linkLabelContent) + + const linkLabelFontSize = toPx(linkLabelDatum.fontSize) ?? getCSSVariableValueInPixels('var(--vis-graph-link-label-font-size)', linkLabelContent.node()) + const linkLabelColor = linkLabelDatum.textColor ?? getLinkLabelTextColor(linkLabelDatum) + if (shouldRenderUseElement) { + linkLabelContent + .attr('href', linkLabelText) + .attr('x', -linkLabelFontSize / 2) + .attr('y', -linkLabelFontSize / 2) + .attr('width', linkLabelFontSize) + .attr('height', linkLabelFontSize) + .style('fill', linkLabelColor) + } else { + linkLabelContent + .text(linkLabelText) + .attr('dy', '0.1em') + .style('font-size', linkLabelFontSize) + .style('fill', linkLabelColor) + } + linkLabelGroup.attr('hidden', null) .style('cursor', linkLabelDatum.cursor) @@ -220,16 +242,9 @@ export function updateLinks .attr('transform', linkLabelTranslate) .style('opacity', 1) - linkLabelContent - .text(linkLabelText) - .attr('dy', '0.1em') - .style('font-size', linkLabelDatum.fontSize) - .style('fill', linkLabelDatum.textColor ?? getLinkLabelTextColor(linkLabelDatum)) - - const shouldBeRenderedAsCircle = linkLabelText.length <= 2 + const shouldBeRenderedAsCircle = linkLabelText.length <= 2 || shouldRenderUseElement const linkLabelPaddingVertical = 4 const linkLabelPaddingHorizontal = shouldBeRenderedAsCircle ? linkLabelPaddingVertical : 8 - const linkLabelFontSize = toPx(linkLabelDatum.fontSize) ?? getCSSVariableValueInPixels('var(--vis-graph-link-label-font-size)', linkLabelContent.node()) const linkLabelWidthPx = estimateStringPixelLength(linkLabelText, linkLabelFontSize) const linkLabelBackgroundBorderRadius = linkLabelDatum.radius ?? (shouldBeRenderedAsCircle ? linkLabelFontSize : 4) const linkLabelBackgroundWidth = (shouldBeRenderedAsCircle ? linkLabelFontSize : linkLabelWidthPx) diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 761ab8fda..a36464e9d 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -3,7 +3,4 @@ export * from './components' export * from './types' export * from './styles/colors' export * from './styles/sizes' -export * from './utils/data' -export * from './utils/text' -export * from './utils/svg' -export * from './utils/color' +export * from './utils' diff --git a/packages/ts/src/utils/index.ts b/packages/ts/src/utils/index.ts new file mode 100644 index 000000000..e4adc31e4 --- /dev/null +++ b/packages/ts/src/utils/index.ts @@ -0,0 +1,12 @@ +export * from './data' +export * from './text' +export * from './svg' +export * from './color' +export * from './path' +export * from './misc' +export * from './type' +export * from './scale' +export * from './d3' +export * from './map' +export * from './style' +export * from './html' diff --git a/packages/website/docs/networks-and-flows/Graph.mdx b/packages/website/docs/networks-and-flows/Graph.mdx index 3b0f1cb2e..68a0d86e7 100644 --- a/packages/website/docs/networks-and-flows/Graph.mdx +++ b/packages/website/docs/networks-and-flows/Graph.mdx @@ -103,7 +103,7 @@ zoom level at initialization to fit to the size of the container. nodeSize={50} /> -### Custom SVG Nodes +### Custom SVG Shapes Alternatively, you can provide `nodeShape` property with custom SVGs to get the exact shape you want. You can either provide it directly as a string in your _StringAccessor_ or for better control over the element, put the shape(s) definition in the container's `svgDefs` property and reference it with @@ -287,6 +287,59 @@ The disabled state appearance can be redefined with these CSS variables: --vis-dark-graph-node-side-label-background-greyout-color: #f1f4f7; ``` +### Custom Rendering `1.5.0` +The _Graph_ component offers extensive customization options for node rendering, allowing you to define how +nodes are displayed at various stages of their lifecycle — such as on entering, updating, zooming, and exiting. +You can inject custom rendering functions using the following configuration properties: + +- `nodeEnterCustomRenderFunction`: Customize the node rendering when a node enters the DOM. +- `nodeUpdateCustomRenderFunction`: Define the rendering when a node updates its position or properties. +- `nodePartialUpdateCustomRenderFunction`: Partially update nodes on specific interactions like mouseover, background click, and brushing. +- `nodeExitCustomRenderFunction`: Customize how nodes are rendered when they exit the DOM. +- `nodeOnZoomCustomRenderFunction`: Adjust node rendering dynamically during zooming or panning. + +These functions provide access to each node’s data (`datum`), the node’s DOM element selection +(`g`), the component configuration (`config`), and the current zoom level (`zoomLevel`). +This gives you full control to modify elements such as SVG shapes, colors, labels, icons, and more. + +```ts +import { select, Selection } from 'd3-selection' +import { GraphNode, GraphConfigInterface } from '@unovis/ts' + +export const nodeEnterCustomRenderFunction = ( + datum: GraphNode, + g: Selection, + config: GraphConfigInterface +) => { + // Initial rendering logic for the node + g.append('circle') + + // Add custom icons, labels, or any additional elements + g.append('text') + .attr('dy', -10) + .attr('text-anchor', 'middle') +} + +export const nodeUpdateCustomRenderFunction = ( + datum: GraphNode, + g: Selection, + config: GraphConfigInterface +) => { + // Update the node's size, color + g.select('circle') + .attr('r', config.nodeSize ?? 20) + .style('fill', config.nodeFill ?? 'steelblue') + + // Update labels or any custom elements based on node data + g.select('text') + .text(datum.id) +} +``` + +Using these functions, you can create highly customized and dynamic node appearances, adapting the visual +representation based on data or user interaction. These functions are invoked for each node in the graph, +providing flexibility for various use cases, from static iconography to interactive, animated elements. + ## Links ### Color, Width and Type Set link color and width with the `linkStroke` and `linkWidth` properties. The default link color can be set with the @@ -308,10 +361,15 @@ Providing an accessor function to `linkArrow` will turn on arrows display on lin `GraphLinkArrowStyle.Single` (`"single"` or simply `true`) or `GraphLinkArrowStyle.Double` (`"double"`) or `null`. sample([undefined, 'single', 'double'])} /> -### Labels -Links can have textual label. When the label is short (two characters or less) it'll be rendered with a circular +### Labels `Updated in 1.5.0` +Links can have textual or custom SVG labels. When the label is short (two characters or less) or an SVG href, it'll be rendered with a circular background similarly to node's [side labels](Graph#on-the-side). Longer labels will have a rectangular background. -To enable links labels you'll need to provide a function to `linkLabel` returning a `GraphLinkLabel` object to display. +To enable link labels you'll need to provide a function for `linkLabel` returning a `GraphLinkLabel` object to display. + +To use custom SVG as labels (available in _Unovis_ 1.5.0), you'll first need to define it in your container [SVG defs](http://localhost:9300/docs/containers/Single_Container#svg-defs) +and provide the `href` to your custom SVG definition using the `text` property of your label. In this case the `fontSize` +property will control the size of your custom SVG label. + ({ text: i*i*i })} /> The default appearance of the labels is controlled with the following CSS variables: @@ -447,7 +505,7 @@ object to `dagreLayoutSettings`. }} /> -### ELK `New in 1.1.0` +### ELK Starting from Unovis version 1.1.0 _Graph_ supports [The Eclipse Layout Kernel](https://www.eclipse.org/elk/) which has [several layout algorithms](https://www.eclipse.org/elk/reference/algorithms.html) available. You can enable ELK by setting `layoutType` to `GraphLayoutType.Elk` (or `"elk"`) and providing the layout configuration via @@ -524,13 +582,37 @@ in the gallery to learn more. ### Precalculated If you want to specify node locations, set `layoutType` to `GraphLayoutType.Precalculated`(or `"precalculated"`). -Then pass in node positions (`x` and `y`) as part of graph data. +Then pass in node positions (`x` and `y`) as part of graph data. Note: if you selected `GraphLayoutType.Precalculated` but fail to pass in `x` and `y`, all your nodes will render at the default positions. ### Non-connected nodes aside If you want non-connected graph nodes to be placed below the layout, set `layoutNonConnectedAside` to `true`. +### Post-Layout Customization `1.5.0` +The `Graph` component includes a `onLayoutCalculated` callback, which provides an opportunity to adjust +the positions or properties of nodes and links after the layout has been calculated. This can be +useful if you need to make final tweaks or apply additional logic once the layout is determined. + +This callback function is triggered with the calculated node and link arrays, allowing you to +inspect and modify their properties directly. For example, you can use this callback to enforce +specific positioning constraints or adjust node/link styles. + +Here’s a basic example that demonstrates how to use the `onLayoutCalculated` callback to adjust node positions: + +```ts +const onLayoutCalculated = (nodes: GraphNode[], links: GraphLink[]) => { + // Modify nodes based on custom criteria + nodes.forEach(node => { + if (node.group === 'special') { + // Set specific positions or styles for nodes in the 'special' group + node.x += 50; + node.y -= 30; + } + }); +}; +``` + ## Fitting the graph into container _Graph_ automatically fits the layout to the container size on every config or data update. However, when the user has moved or zoomed the graph there's some level of tolerance after which automatic fitting will be disabled. The tolerance @@ -704,9 +786,45 @@ public fitView () If you use React or Angular, you can access the component instance for calling these methods by using [`useRef`](https://react.dev/reference/react/useRef) or [`ViewChild`](https://angular.io/api/core/ViewChild) respectively. +### Callbacks +The _Graph_ component supports a comprehensive set of interaction callbacks, giving you control +over node dragging, zooming/panning. These callbacks allow you to add +custom behavior and responses during user interactions, enhancing the interactivity and +responsiveness of the graph. + +#### Node Dragging Callbacks +You can define custom actions for node dragging with the following callbacks: + +- `onNodeDragStart`: Triggered when a node drag starts. +- `onNodeDrag`: Called continuously as a node is being dragged. +- `onNodeDragEnd`: Invoked when a node drag operation ends. + +Each of these callbacks receives the node data and the drag event, allowing for actions such +as updating other elements based on the dragged node’s position or applying visual effects +during the drag. ```ts -onZoom: (zoomScale: number, zoomScaleExtent: number) => void; +onNodeDragStart: (n: GraphNode, event: D3DragEvent, unknown>) => void | undefined; +onNodeDrag: (n: GraphNode, event: D3DragEvent, unknown>) => void | undefined; +onNodeDragEnd: (n: GraphNode, event: D3DragEvent, unknown>) => void | undefined; +``` + +#### Zoom and Pan Callbacks + +For handling zoom and pan interactions, you can use these callbacks: + +- `onZoomStart`: Fires when a zoom or pan operation begins. +- `onZoom`: Triggered continuously during zooming or panning, providing the current +zoom scale and transform details. +- `onZoomEnd`: Called when zooming or panning ends. + +These callbacks allow you to dynamically adjust graph elements, update UI components, or log +zoom and pan activities. + +```ts +onZoom: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void; +onZoomStart: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void; +onZoomEnd: (zoomScale: number, zoomScaleExtent: [number, number], event: D3ZoomEvent | undefined, transform: ZoomTransform) => void; ``` ## Multiple Node Drag @@ -727,11 +845,38 @@ The appearance of the "brushed" nodes can be customized with the following CSS v --vis-graph-brushed-node-icon-fill-color; ``` -Additionally, there are two callback functions in Graph's configuration that can be used to see which nodes are being brushed and dragged. +### Callbacks +Additionally, there are two callback functions in _Graph_'s configuration that can be used to see which nodes are being brushed and dragged: + +- `onNodeSelectionBrush`: Fires when nodes are selected using the brushing tool. It provides an array of selected nodes and the brush event, making it ideal for grouping or highlighting nodes. +- `onNodeSelectionDrag`: Called during a multi-node drag operation, enabling you to manage grouped node movements or apply custom interactions. + + +```ts +onNodeSelectionBrush: (selectedNodes: GraphNode[], event: D3BrushEvent | undefined) => void; +onNodeSelectionDrag: (selectedNodes: GraphNode[], event: D3DragEvent, unknown>) => void; +``` + +## Post-Render Customization `1.5.0` +The _Graph_ component provides an `onRenderComplete` callback function that allows you to add custom +elements to the graph’s canvas after the rendering is fully complete. This function is especially +useful for layering additional elements or annotations on top of the existing nodes and links. + +This callback receives several parameters, including the canvas selection (`g`),arrays of nodes and +links, and configuration details. With this access, you can append, update, or transform elements +as needed to enhance the visual output. ```ts -onNodeSelectionBrush (selectedNodes: GraphNode[], event: D3BrushEvent) void; -onNodeSelectionDrag (selectedNodes: GraphNode[], event: D3BrushEvent) void; +onRenderComplete ( + g: Selection, + nodes: GraphNode[], + links: GraphLink[], + config: GraphConfigInterface, + duration: number, + zoomLevel: number, + width: number, + height: number +) => void; ``` ## Events