import React from 'react'; import type { GraphViewElementEventType, GraphViewNode, GraphViewNodeRenderContext, GraphViewRenderedNode, GraphViewSize, } from './GraphView'; interface GraphNodeOverlayProps { readonly nodes: readonly GraphViewNode[]; readonly renderedNodes: readonly GraphViewRenderedNode[]; readonly renderNode: (context: GraphViewNodeRenderContext) => React.ReactNode; readonly onNodeEvent: (id: string, type: GraphViewElementEventType) => void; readonly onNodeSize: (id: string, size: GraphViewSize) => void; } /*** Render optional React node content above Cytoscape at its authoritative rendered positions. */ export function GraphNodeOverlay(props: GraphNodeOverlayProps) { const nodesById = React.useMemo( () => new Map(props.nodes.map((node) => [node.id, node] as const)), [props.nodes], ); return (
{props.renderedNodes.map((renderedNode) => { const node = nodesById.get(renderedNode.id); if (!node) return null; return ( ); })}
); } interface MeasuredGraphNodeProps { readonly node: GraphViewNode; readonly renderedNode: GraphViewRenderedNode; readonly renderNode: GraphNodeOverlayProps['renderNode']; readonly onNodeEvent: GraphNodeOverlayProps['onNodeEvent']; readonly onNodeSize: GraphNodeOverlayProps['onNodeSize']; } /*** Measure one rich node at model scale and translate its DOM interactions into graph events. */ function MeasuredGraphNode(props: MeasuredGraphNodeProps) { const elementRef = React.useRef(null); const { node, onNodeSize, renderedNode } = props; React.useLayoutEffect(() => { const element = elementRef.current; if (element === null) return; reportElementSize(element, node.id, onNodeSize); const Observer = readResizeObserverConstructor(); if (Observer === null) return; const observer = new Observer(() => reportElementSize(element, node.id, onNodeSize)); observer.observe(element); return () => observer.disconnect(); }, [node.id, onNodeSize]); return (
{ elementRef.current = element; }} style={{ left: renderedNode.position.x, pointerEvents: 'auto', position: 'absolute', top: renderedNode.position.y, transform: `translate(-50%, -50%) scale(${renderedNode.zoom})`, transformOrigin: 'center', }} onClick={() => props.onNodeEvent(node.id, 'press')} onDoubleClick={() => props.onNodeEvent(node.id, 'double-press')} onMouseEnter={() => props.onNodeEvent(node.id, 'pointer-enter')} onMouseLeave={() => props.onNodeEvent(node.id, 'pointer-leave')} > {props.renderNode({ hovered: renderedNode.hovered, node, selected: renderedNode.selected, zoom: renderedNode.zoom, })}
); } /*** Report unscaled DOM dimensions only when a measurable node has positive size. */ function reportElementSize( element: unknown, nodeId: string, onNodeSize: (id: string, size: GraphViewSize) => void, ) { const measured = element as { readonly offsetHeight?: unknown; readonly offsetWidth?: unknown; }; if (typeof measured.offsetWidth !== 'number' || typeof measured.offsetHeight !== 'number') return; if (measured.offsetWidth <= 0 || measured.offsetHeight <= 0) return; onNodeSize(nodeId, { width: measured.offsetWidth, height: measured.offsetHeight, }); } /*** Read ResizeObserver without requiring the DOM lib in the package TypeScript target. */ function readResizeObserverConstructor(): ResizeObserverConstructorLike | null { const observer = ( globalThis as unknown as { ResizeObserver?: ResizeObserverConstructorLike; } ).ResizeObserver; return observer ?? null; } interface ResizeObserverLike { disconnect(): void; observe(target: unknown): void; } type ResizeObserverConstructorLike = new (callback: () => void) => ResizeObserverLike;