@gravity-ui/graph
Version:
Modern graph editor component
94 lines (93 loc) • 4.34 kB
JavaScript
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 }));
})));
});