UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

94 lines (93 loc) 4.34 kB
import React, { memo, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { Block as CanvasBlock } from "../components/canvas/blocks/Block"; import { GraphState } from "../graph"; import { ESchedulerPriority } from "../lib"; import { ECameraScaleLevel } from "../services/camera/CameraService"; import { debounce } from "../utils/functions"; import { useSignal } from "./hooks"; import { useGraphEvent } from "./hooks/useGraphEvents"; import { useCompareState } from "./utils/hooks/useCompareState"; import { useFn } from "./utils/hooks/useFn"; export const Block = memo((props) => { const block = useSignal(props.blockState.$state); if (!block) return null; return props.renderBlock(props.graphObject, block, props.blockState); }); /** * Optimized comparison using Set instead of map.sort + lodash.isEqual * For proof that Set is faster than map.sort + lodash.isEqual run `node benchmarks/blocklist-comparison.bench.js` * * Note: lodash.isEqual is more memory-efficient for large lists, but we expect no more than 100 blocks * in detailed view, where Set-based comparison significantly outperforms map.sort + lodash.isEqual */ const hasBlockListChanged = (newStates, oldStates) => { if (newStates.length !== oldStates.length) return true; const oldIds = new Set(oldStates.map((state) => state.id)); return newStates.some((state) => !oldIds.has(state.id)); }; export const BlocksList = memo(function BlocksList({ renderBlock, graphObject }) { const [blockStates, setBlockStates] = useState([]); const [graphState, setGraphState] = useCompareState(graphObject.state); const [cameraScaleLevel, setCameraScaleLevel] = useState(graphObject.cameraService.getCameraBlockScaleLevel()); // Pure function to check if rendering is allowed const isDetailedScale = useFn((scale = graphObject.cameraService.getCameraScale()) => { return graphObject.cameraService.getCameraBlockScaleLevel(scale) === ECameraScaleLevel.Detailed; }); const updateBlockList = useFn(() => { if (!isDetailedScale()) { setBlockStates([]); return; } setBlockStates((prevStates) => { const statesInRect = graphObject .getElementsInViewport([CanvasBlock]) .map((component) => component.connectedState); return hasBlockListChanged(statesInRect, prevStates) ? statesInRect : prevStates; }); }); const scheduleListUpdate = useMemo(() => { return debounce(() => updateBlockList(), { priority: ESchedulerPriority.HIGHEST, frameInterval: 1, }); }, [updateBlockList]); // Sync graph state useGraphEvent(graphObject, "state-change", () => { setGraphState(graphObject.state); }); useEffect(() => { setGraphState(graphObject.state); }, [graphObject, setGraphState]); // Handle camera changes and render mode switching useGraphEvent(graphObject, "camera-change", ({ scale }) => { setCameraScaleLevel((level) => level === graphObject.cameraService.getCameraBlockScaleLevel(scale) ? level : graphObject.cameraService.getCameraBlockScaleLevel(scale)); scheduleListUpdate(); }); // Subscribe to hitTest updates to catch when blocks become available in viewport useEffect(() => { const handler = () => { scheduleListUpdate(); }; graphObject.hitTest.on("update", handler); return () => { graphObject.hitTest.off("update", handler); }; }, [graphObject, scheduleListUpdate]); // Check initial camera scale on mount to handle cases where zoomTo() is called // during initialization before the camera-change event subscription is active useLayoutEffect(() => { scheduleListUpdate(); return () => { scheduleListUpdate.cancel(); }; }, [graphObject, scheduleListUpdate]); return (React.createElement(React.Fragment, null, graphState === GraphState.READY && cameraScaleLevel === ECameraScaleLevel.Detailed && blockStates.map((blockState) => { return (React.createElement(Block, { key: blockState.id, renderBlock: renderBlock, graphObject: graphObject, blockState: blockState })); }))); });