diff --git a/examples/vite-app/src/examples/InteractiveMinimap/index.tsx b/examples/vite-app/src/examples/InteractiveMinimap/index.tsx index 66cab6a0..cde9b722 100644 --- a/examples/vite-app/src/examples/InteractiveMinimap/index.tsx +++ b/examples/vite-app/src/examples/InteractiveMinimap/index.tsx @@ -17,35 +17,70 @@ const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node); const initialNodes: Node[] = [ { id: '1', - type: 'input', data: { label: 'Node 1' }, position: { x: 0, y: 0 }, - className: 'light', }, { id: '2', data: { label: 'Node 2' }, position: { x: 0, y: 200 }, - className: 'light', }, { id: '3', data: { label: 'Node 3' }, - position: { x: 200, y: 200 }, - className: 'light', + position: { x: 200, y: 0 }, }, + { id: '4', data: { label: 'Node 4' }, - position: { x: 200, y: 400 }, - className: 'light', + position: { x: 1000, y: 0 }, + }, + { + id: '5', + data: { label: 'Node 5' }, + position: { x: 1000, y: 200 }, + }, + { + id: '6', + data: { label: 'Node 6' }, + position: { x: 800, y: 0 }, + }, + + { + id: '7', + data: { label: 'Node 4' }, + position: { x: 0, y: 1000 }, + }, + { + id: '8', + data: { label: 'Node 5' }, + position: { x: 0, y: 800 }, + }, + { + id: '9', + data: { label: 'Node 6' }, + position: { x: 200, y: 1000 }, + }, + + { + id: '10', + data: { label: 'Node 4' }, + position: { x: 1000, y: 1000 }, + }, + { + id: '11', + data: { label: 'Node 5' }, + position: { x: 800, y: 1000 }, + }, + { + id: '12', + data: { label: 'Node 6' }, + position: { x: 1000, y: 800 }, }, ]; -const initialEdges: Edge[] = [ - { id: 'e1-2', source: '1', target: '2', animated: true }, - { id: 'e1-3', source: '1', target: '3' }, -]; +const initialEdges: Edge[] = []; const defaultEdgeOptions = { zIndex: 0 }; diff --git a/packages/interactive-minimap/src/MiniMap.tsx b/packages/interactive-minimap/src/MiniMap.tsx index e5a7927e..a8cbd404 100644 --- a/packages/interactive-minimap/src/MiniMap.tsx +++ b/packages/interactive-minimap/src/MiniMap.tsx @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo, useEffect, useRef } from 'react'; +import { memo, MouseEvent, useEffect, useRef } from 'react'; import cc from 'classcat'; import shallow from 'zustand/shallow'; import { zoom, D3ZoomEvent, zoomIdentity } from 'd3-zoom'; -import { select } from 'd3-selection'; +import { select, pointer } from 'd3-selection'; import { useStore, getRectOfNodes, @@ -13,7 +13,6 @@ import { getBoundsOfRects, useStoreApi, useReactFlow, - XYPosition, } from '@reactflow/core'; import MiniMapNode from './MiniMapNode'; @@ -38,6 +37,8 @@ const selector = (s: ReactFlowState) => { viewBB, boundingRect: nodes.length > 0 ? getBoundsOfRects(getRectOfNodes(nodes), viewBB) : viewBB, rfId: s.rfId, + width: s.width, + height: s.height, }; }; @@ -49,17 +50,16 @@ function MiniMap({ style, className, nodeStrokeColor = '#555', - nodeColor = '#fff', + nodeColor = '#999', nodeClassName = '', nodeBorderRadius = 5, nodeStrokeWidth = 2, - maskColor = 'rgb(240, 242, 243, 0.7)', + maskColor = 'rgb(200, 200, 200, 0.9)', position = 'bottom-right', }: MiniMapProps) { const store = useStoreApi(); const svg = useRef(null); - const initialized = useRef(false); - const { boundingRect, viewBB, nodes, rfId } = useStore(selector, shallow); + const { boundingRect, viewBB, nodes, rfId, width: w, height: h } = useStore(selector, shallow); const elementWidth = (style?.width as number) ?? defaultWidth; const elementHeight = (style?.height as number) ?? defaultHeight; const nodeColorFunc = getAttrFunction(nodeColor); @@ -77,33 +77,27 @@ function MiniMap({ const height = viewHeight + offset * 2; const shapeRendering = typeof window === 'undefined' || !!window.chrome ? 'crispEdges' : 'geometricPrecision'; const labelledBy = `${ARIA_LABEL_KEY}-${rfId}`; - const { setCenter } = useReactFlow(); - const startTransform = useRef({ x: 0, y: 0 }); + const { setCenter, setViewport, project } = useReactFlow(); + const startTransform = useRef<[number, number]>([0, 0]); const viewScaleRef = useRef(0); - const viewScaleWRef = useRef(0); - const viewScaleHRef = useRef(0); - viewScaleRef.current = viewScale; - viewScaleWRef.current = scaledWidth; - viewScaleHRef.current = scaledHeight; + viewScaleRef.current = Math.max(w / elementWidth, h / elementHeight); + + const onClick = (event: MouseEvent) => { + const rfCoord = pointer(event); + + console.log(rfCoord); + }; useEffect(() => { - if (!initialized.current && svg.current) { - const bounds = svg.current.getBoundingClientRect() || { left: 0, top: 0 }; - + if (svg.current) { const selection = select(svg.current as Element); const zoomHandler = zoom() .on('start', (event: D3ZoomEvent) => { - const { transform } = store.getState(); + const rfCoord = pointer(event); - const px = (event.sourceEvent.clientX - bounds.left) * viewScaleRef.current; - const py = (event.sourceEvent.clientY - bounds.top) * viewScaleRef.current; - - startTransform.current = { - x: px - transform[0] / transform[2], - y: py - transform[1] / transform[2], - }; + startTransform.current = rfCoord; }) .on('zoom.wheel', (event: D3ZoomEvent) => { const { transform, d3Selection, d3Zoom } = store.getState(); @@ -117,29 +111,26 @@ function MiniMap({ (event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) * 10; const zoom = transform[2] * Math.pow(2, pinchDelta); - d3Zoom.scaleTo(d3Selection, zoom, [startTransform.current.x, startTransform.current.y]); + + d3Zoom.scaleTo(d3Selection, zoom, startTransform.current); }) .on('zoom', (event: D3ZoomEvent) => { - if (event.sourceEvent.type !== 'mousemove') { + const { transform, d3Selection, d3Zoom } = store.getState(); + if (event.sourceEvent.type !== 'mousemove' || !d3Selection || !d3Zoom) { return; } - const { transform, d3Selection, d3Zoom } = store.getState(); - const px = (event.sourceEvent.clientX - bounds.left) * viewScaleRef.current; - const py = (event.sourceEvent.clientY - bounds.top) * viewScaleRef.current; - const position = { - x: (px - startTransform.current.x) * transform[2], - y: (py - startTransform.current.y) * transform[2], + x: transform[0] - event.sourceEvent.movementX * viewScaleRef.current * transform[2], + y: transform[1] - event.sourceEvent.movementY * viewScaleRef.current * transform[2], }; const nextTransform = zoomIdentity.translate(position.x, position.y).scale(transform[2]); - d3Zoom!.transform(d3Selection!, nextTransform); + d3Zoom.transform(d3Selection, nextTransform); }); selection.call(zoomHandler); - initialized.current = true; } - }, [setCenter]); + }, [setCenter, setViewport, project]); return ( @@ -150,6 +141,7 @@ function MiniMap({ role="img" aria-labelledby={labelledBy} ref={svg} + onClick={onClick} > React Flow mini map {nodes.map((node) => { diff --git a/packages/interactive-minimap/src/MiniMapDrag.tsx b/packages/interactive-minimap/src/MiniMapDrag.tsx new file mode 100644 index 00000000..e5a7927e --- /dev/null +++ b/packages/interactive-minimap/src/MiniMapDrag.tsx @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { memo, useEffect, useRef } from 'react'; +import cc from 'classcat'; +import shallow from 'zustand/shallow'; +import { zoom, D3ZoomEvent, zoomIdentity } from 'd3-zoom'; +import { select } from 'd3-selection'; +import { + useStore, + getRectOfNodes, + ReactFlowState, + Rect, + Panel, + getBoundsOfRects, + useStoreApi, + useReactFlow, + XYPosition, +} from '@reactflow/core'; + +import MiniMapNode from './MiniMapNode'; +import { MiniMapProps, GetMiniMapNodeAttribute } from './types'; + +declare const window: any; + +const defaultWidth = 200; +const defaultHeight = 150; + +const selector = (s: ReactFlowState) => { + const nodes = Array.from(s.nodeInternals.values()); + const viewBB: Rect = { + x: -s.transform[0] / s.transform[2], + y: -s.transform[1] / s.transform[2], + width: s.width / s.transform[2], + height: s.height / s.transform[2], + }; + + return { + nodes: nodes.filter((node) => !node.hidden && node.width && node.height), + viewBB, + boundingRect: nodes.length > 0 ? getBoundsOfRects(getRectOfNodes(nodes), viewBB) : viewBB, + rfId: s.rfId, + }; +}; + +const getAttrFunction = (func: any): GetMiniMapNodeAttribute => (func instanceof Function ? func : () => func); + +const ARIA_LABEL_KEY = 'react-flow__minimap-desc'; + +function MiniMap({ + style, + className, + nodeStrokeColor = '#555', + nodeColor = '#fff', + nodeClassName = '', + nodeBorderRadius = 5, + nodeStrokeWidth = 2, + maskColor = 'rgb(240, 242, 243, 0.7)', + position = 'bottom-right', +}: MiniMapProps) { + const store = useStoreApi(); + const svg = useRef(null); + const initialized = useRef(false); + const { boundingRect, viewBB, nodes, rfId } = useStore(selector, shallow); + const elementWidth = (style?.width as number) ?? defaultWidth; + const elementHeight = (style?.height as number) ?? defaultHeight; + const nodeColorFunc = getAttrFunction(nodeColor); + const nodeStrokeColorFunc = getAttrFunction(nodeStrokeColor); + const nodeClassNameFunc = getAttrFunction(nodeClassName); + const scaledWidth = boundingRect.width / elementWidth; + const scaledHeight = boundingRect.height / elementHeight; + const viewScale = Math.max(scaledWidth, scaledHeight); + const viewWidth = viewScale * elementWidth; + const viewHeight = viewScale * elementHeight; + const offset = 5 * viewScale; + const x = boundingRect.x - (viewWidth - boundingRect.width) / 2 - offset; + const y = boundingRect.y - (viewHeight - boundingRect.height) / 2 - offset; + const width = viewWidth + offset * 2; + const height = viewHeight + offset * 2; + const shapeRendering = typeof window === 'undefined' || !!window.chrome ? 'crispEdges' : 'geometricPrecision'; + const labelledBy = `${ARIA_LABEL_KEY}-${rfId}`; + const { setCenter } = useReactFlow(); + const startTransform = useRef({ x: 0, y: 0 }); + const viewScaleRef = useRef(0); + const viewScaleWRef = useRef(0); + const viewScaleHRef = useRef(0); + + viewScaleRef.current = viewScale; + viewScaleWRef.current = scaledWidth; + viewScaleHRef.current = scaledHeight; + + useEffect(() => { + if (!initialized.current && svg.current) { + const bounds = svg.current.getBoundingClientRect() || { left: 0, top: 0 }; + + const selection = select(svg.current as Element); + + const zoomHandler = zoom() + .on('start', (event: D3ZoomEvent) => { + const { transform } = store.getState(); + + const px = (event.sourceEvent.clientX - bounds.left) * viewScaleRef.current; + const py = (event.sourceEvent.clientY - bounds.top) * viewScaleRef.current; + + startTransform.current = { + x: px - transform[0] / transform[2], + y: py - transform[1] / transform[2], + }; + }) + .on('zoom.wheel', (event: D3ZoomEvent) => { + const { transform, d3Selection, d3Zoom } = store.getState(); + + if (event.sourceEvent.type !== 'wheel' || !d3Selection || !d3Zoom) { + return; + } + + const pinchDelta = + -event.sourceEvent.deltaY * + (event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) * + 10; + const zoom = transform[2] * Math.pow(2, pinchDelta); + d3Zoom.scaleTo(d3Selection, zoom, [startTransform.current.x, startTransform.current.y]); + }) + .on('zoom', (event: D3ZoomEvent) => { + if (event.sourceEvent.type !== 'mousemove') { + return; + } + + const { transform, d3Selection, d3Zoom } = store.getState(); + const px = (event.sourceEvent.clientX - bounds.left) * viewScaleRef.current; + const py = (event.sourceEvent.clientY - bounds.top) * viewScaleRef.current; + + const position = { + x: (px - startTransform.current.x) * transform[2], + y: (py - startTransform.current.y) * transform[2], + }; + + const nextTransform = zoomIdentity.translate(position.x, position.y).scale(transform[2]); + d3Zoom!.transform(d3Selection!, nextTransform); + }); + selection.call(zoomHandler); + initialized.current = true; + } + }, [setCenter]); + + return ( + + + React Flow mini map + {nodes.map((node) => { + return ( + + ); + })} + + + + ); +} + +MiniMap.displayName = 'MiniMap'; + +export default memo(MiniMap);