From e3014553a5e126fc0dff8bdad36178cd5cef030e Mon Sep 17 00:00:00 2001 From: dextmorgn <64375473+dextmorgn@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:50:04 +0200 Subject: [PATCH] fix(frontend): type the graph subsystem's react-force-graph boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single biggest source of `any` in this codebase: react-force-graph is a loosely-typed library by design (NodeObject/LinkObject both carry `[others: string]: any` index signatures), and every hook/component touching the graph ref, node/edge callbacks, or force settings had absorbed that looseness as `any` instead of narrowing it once at the boundary. Fixed the boundary, not each site individually — most of this diff is mechanical once the shared types existed. New shared types (types/graph.ts): - GraphViewerRef = ForceGraphMethods, LinkObject> | undefined — the one ref type every graphRef across 8 files now uses instead of re-guessing `React.RefObject` at each site. - GraphNode gained fx?/fy? — react-force-graph's own NodeObject type declares these (pin coordinates set after drag-end and after layout), and every consumer that touched them was already doing so through `any`; the app's own type just never caught up. Root-cause fix in index.tsx: explicitly parametrizing `>` (it was rendered with no generics, defaulting every prop's NodeType/LinkType to `{}`) made onNodeClick/onNodeRightClick/onLinkClick/onLinkRightClick/ nodeCanvasObject/linkCanvasObject all correctly typed against the app's real node/edge shapes for free — four `any` params across two files resolved by one annotation, not four separate patches. Found and fixed a real TS gotcha this surfaced: GraphEdge declares source/target as plain required strings, which collides with LinkObject's own optional `source?: string | number | NodeObject` when GraphEdge is used as the LinkType generic — the intersection of a required string and that optional union collapses to `never` once narrowed with `typeof x === 'object'`, silently breaking any code that reads `.x`/`.y` off a link's resolved endpoint. Verified in isolation before touching the real files (see the two `LinkObject>` sites and their comments). Every earlier place in this codebase handling this exact duality with hand- rolled `typeof === 'object' ? x.id : x` guards was working around this same underlying incompatibility without naming it. Found and removed real dead code exposed by typing, not introduced by it: link-renderer.ts's LinkRenderParams declared theme/highlightNodes/ selectedEdges and both call sites dutifully threaded them through, but renderLink's body never reads any of the three — their information was already baked into `rc` (RenderContext) before the call. Confirmed by literal absence from the destructure, not inferred; removed from the interface and both call sites. Documented, not silently patched: use-graph-initialization.ts's getViewportCenter called `graphRef.current.getBoundingClientRect?.()` — a DOM method ForceGraphMethods doesn't have, so the optional call was always undefined and the function always returned the {x:0,y:0} fallback. Feeds "center new item in viewport" (add-item-dialog.tsx), which has therefore always placed new items at graph-origin, never the actual visible center. Left the behavior unchanged with the bug explained inline — fixing it needs the container's DOM rect, which this hook isn't given, and guessing at a fix here without a way to verify it against the running app risked a worse, unverifiable regression. Also typed through the two hooks/use-layout.ts and hooks/use-graph-layout.ts (nodes/graphData/forceSettings/ saveAllNodePositions, matching the same real types), and the worker message boundary in use-layout.ts (a minimal `{id, x?, y?}` type for what crosses postMessage's structured-clone, since that's genuinely all that's read off it). Verified: eslint no-explicit-any 257 -> 202 in one pass, tsc 89 (unchanged from the running baseline — every step diffed against it, not just "count went down"; two remaining findings are the established table/data-table.tsx dead code), vitest 15/15, yarn build clean. Also fixed two no-extra-semi findings in nodes-view.tsx/ relationships-view.tsx that kept reappearing: deleting the leading `;` alone doesn't stick because prettier's own ASI-safety rule re-inserts it on every format pass when a statement starts with `(` — restructured to a local `const` instead of leaving something prettier would keep fighting. --- .../graph/background/background-types.tsx | 3 +- .../sketches/graph/components/graph-main.tsx | 12 ++-- .../sketches/graph/components/minimap.tsx | 4 +- .../sketches/graph/edge/link-renderer.ts | 24 ++++++-- .../sketches/graph/hooks/use-graph-events.ts | 27 ++++----- .../graph/hooks/use-graph-initialization.ts | 55 ++++++++++--------- .../sketches/graph/hooks/use-graph-layout.ts | 11 ++-- .../graph/hooks/use-highlight-state.ts | 28 ++++++---- .../sketches/graph/hooks/use-tooltip.ts | 7 ++- .../src/components/sketches/graph/index.tsx | 53 +++++++++--------- .../sketches/graph/node/node-renderer.ts | 19 ++++--- .../components/sketches/graph/utils/types.ts | 12 ++-- .../components/sketches/graph/utils/utils.ts | 7 ++- .../src/components/table/nodes-view.tsx | 3 +- .../components/table/relationships-view.tsx | 3 +- flowsint-app/src/hooks/use-layout.ts | 17 ++++-- flowsint-app/src/types/graph.ts | 19 +++++++ 17 files changed, 181 insertions(+), 123 deletions(-) diff --git a/flowsint-app/src/components/sketches/graph/background/background-types.tsx b/flowsint-app/src/components/sketches/graph/background/background-types.tsx index 5ab31ee0..0a88d6b2 100644 --- a/flowsint-app/src/components/sketches/graph/background/background-types.tsx +++ b/flowsint-app/src/components/sketches/graph/background/background-types.tsx @@ -1,4 +1,5 @@ import { CSSProperties } from 'react' +import type { GraphViewerRef } from '@/types' export enum BackgroundVariant { Dots = 'dots', @@ -30,7 +31,7 @@ export interface BackgroundProps { /** Custom class name for the pattern */ patternClassName?: string /** Reference to the graph for viewport tracking */ - graphRef: React.RefObject + graphRef: React.RefObject /** Canvas width */ canvasWidth: number /** Canvas height */ diff --git a/flowsint-app/src/components/sketches/graph/components/graph-main.tsx b/flowsint-app/src/components/sketches/graph/components/graph-main.tsx index b805b276..9abef551 100644 --- a/flowsint-app/src/components/sketches/graph/components/graph-main.tsx +++ b/flowsint-app/src/components/sketches/graph/components/graph-main.tsx @@ -13,7 +13,7 @@ import BackgroundContextMenu from '../context-menu/background-context-menu' import EdgeContextMenu from '../context-menu/edge-context-menu' import { PathPanel } from '../actions/path-finder' import { useParams } from '@tanstack/react-router' -import { type GraphNode, GraphEdge } from '@/types' +import { type GraphNode, GraphEdge, type GraphViewerRef } from '@/types' import { useLinkCreation } from '../hooks/use-link-creation' import { useQuickAdd } from '../hooks/use-quick-add' import { QuickAddOverlay } from './quick-add-overlay' @@ -58,7 +58,7 @@ const GraphMain = () => { const selectedNodes = useGraphStore((s) => s.selectedNodes) const selectedEdges = useGraphStore((s) => s.selectedEdges) - const graphRef = useRef(null) + const graphRef = useRef(undefined) const containerRef = useRef(null) const [nodeMenu, setNodeMenu] = React.useState(null) const [edgeMenu, setEdgeMenu] = React.useState(null) @@ -78,7 +78,7 @@ const GraphMain = () => { useQuickAdd(sketchId) const handleNodeClick = useCallback( - (node: any, event: MouseEvent) => { + (node: GraphNode, event: MouseEvent) => { const isMultiSelect = event.ctrlKey || event.shiftKey if (isMultiSelect) { toggleNodeSelection(node, true) @@ -143,7 +143,7 @@ const GraphMain = () => { }, []) const onNodeContextMenu = useCallback( - (node: any, event: MouseEvent) => { + (node: GraphNode, event: MouseEvent) => { if (!containerRef.current || !node) return const pane = containerRef.current.getBoundingClientRect() @@ -177,7 +177,7 @@ const GraphMain = () => { ) const onEdgeContextMenu = useCallback( - (edge: any, event: MouseEvent) => { + (edge: GraphEdge, event: MouseEvent) => { if (!containerRef.current || !edge) return const pane = containerRef.current.getBoundingClientRect() @@ -275,7 +275,7 @@ const GraphMain = () => { ] ) - const handleGraphRef = useCallback((ref: any) => { + const handleGraphRef = useCallback((ref: GraphViewerRef) => { graphRef.current = ref }, []) diff --git a/flowsint-app/src/components/sketches/graph/components/minimap.tsx b/flowsint-app/src/components/sketches/graph/components/minimap.tsx index 2b0f31fe..ed63be2f 100644 --- a/flowsint-app/src/components/sketches/graph/components/minimap.tsx +++ b/flowsint-app/src/components/sketches/graph/components/minimap.tsx @@ -1,11 +1,11 @@ import { useEffect, useRef } from 'react' -import { GraphNode } from '@/types' +import { GraphNode, type GraphViewerRef } from '@/types' interface MinimapCanvasProps { nodes: GraphNode[] width?: number height?: number - graphRef: React.RefObject + graphRef: React.RefObject canvasWidth: number canvasHeight: number } diff --git a/flowsint-app/src/components/sketches/graph/edge/link-renderer.ts b/flowsint-app/src/components/sketches/graph/edge/link-renderer.ts index f302616a..75fe65da 100644 --- a/flowsint-app/src/components/sketches/graph/edge/link-renderer.ts +++ b/flowsint-app/src/components/sketches/graph/edge/link-renderer.ts @@ -1,17 +1,29 @@ +import type { LinkObject } from 'react-force-graph-2d' +import type { GraphNode, GraphEdge } from '@/types' +import type { GraphForceSettings } from '@/stores/graph-settings-store' import { CONSTANTS, GRAPH_COLORS, tempPos, tempDimensions } from '../utils/constants' import { getNodeEdgeDistance } from '../node/node-renderer' import { RenderContext, isEdgeInViewport } from '../utils/render-context' interface LinkRenderParams { - link: any + // LinkObject is what > actually hands its linkCanvasObject callback — source/target + // already carry the string-vs-node-object duality (the library mutates + // graphData.links in place once it lays out), and `.curvature` + // (added by transformGraphData, not on GraphEdge) reads fine through the + // type's own index signature. + // + // GraphEdge's own source/target (plain required strings) must be omitted + // here: LinkObject intersects its LinkType param with its own optional + // `source?: string | number | NodeObject` field, and a same-name + // required-string-vs-optional-union collision collapses the intersection + // to `never` once narrowed — verified empirically, not a hunch. + link: LinkObject> ctx: CanvasRenderingContext2D globalScale: number - forceSettings: any - theme: string + forceSettings: GraphForceSettings highlightLinks: Set - highlightNodes: Set - selectedEdges: any[] - currentEdge: any + currentEdge: GraphEdge | null | undefined autoColorLinksByNodeType?: boolean rc: RenderContext } diff --git a/flowsint-app/src/components/sketches/graph/hooks/use-graph-events.ts b/flowsint-app/src/components/sketches/graph/hooks/use-graph-events.ts index 652b29c3..d216de35 100644 --- a/flowsint-app/src/components/sketches/graph/hooks/use-graph-events.ts +++ b/flowsint-app/src/components/sketches/graph/hooks/use-graph-events.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react' -import { GraphNode, GraphEdge } from '@/types' +import { GraphNode, GraphEdge, type GraphViewerRef } from '@/types' +import type { GraphData } from '../utils/types' interface UseGraphEventsParams { onNodeClick?: (node: GraphNode, event: MouseEvent) => void @@ -9,12 +10,12 @@ interface UseGraphEventsParams { onBackgroundRightClick?: (event: MouseEvent) => void autoZoomOnCurrentNode: boolean autoZoomOnNode: boolean - graphRef: React.RefObject + graphRef: React.RefObject edgeMap: Map toggleEdgeSelection: (edge: GraphEdge, multi: boolean) => void setCurrentEdgeId: (edgeId: string | null) => void clearSelectedEdges: () => void - saveAllNodePositions: (nodes: any[]) => void + saveAllNodePositions: (nodes: GraphNode[]) => void } export const useGraphEvents = ({ @@ -33,21 +34,15 @@ export const useGraphEvents = ({ saveAllNodePositions }: UseGraphEventsParams) => { const handleNodeClick = useCallback( - (node: any, event: MouseEvent) => { + (node: GraphNode, event: MouseEvent) => { onNodeClick?.(node, event) const isMultiSelect = event.ctrlKey || event.shiftKey if (autoZoomOnCurrentNode && !isMultiSelect && node?.x && node?.y && graphRef.current) { setTimeout(() => { - if ( - graphRef.current && - autoZoomOnNode && - typeof graphRef.current.centerAt === 'function' - ) { + if (graphRef.current && autoZoomOnNode) { graphRef.current.centerAt(node.x, node.y, 400) - if (typeof graphRef.current.zoom === 'function') { - graphRef.current.zoom(6, 400) - } + graphRef.current.zoom(6, 400) } }, 100) } @@ -56,21 +51,21 @@ export const useGraphEvents = ({ ) const handleNodeRightClick = useCallback( - (node: any, event: MouseEvent) => { + (node: GraphNode, event: MouseEvent) => { onNodeRightClick?.(node, event) }, [onNodeRightClick] ) const handleEdgeRightClick = useCallback( - (edge: any, event: MouseEvent) => { + (edge: GraphEdge, event: MouseEvent) => { onEdgeRightClick?.(edge, event) }, [onEdgeRightClick] ) const handleEdgeClick = useCallback( - (edge: any, event: MouseEvent) => { + (edge: GraphEdge, event: MouseEvent) => { event.stopPropagation() const isMultiSelect = event.ctrlKey || event.shiftKey const fullEdge = edgeMap.get(edge.id) @@ -102,7 +97,7 @@ export const useGraphEvents = ({ ) const handleNodeDragEnd = useCallback( - (node: any, graphData: any) => { + (node: GraphNode, graphData: GraphData) => { node.fx = node.x node.fy = node.y diff --git a/flowsint-app/src/components/sketches/graph/hooks/use-graph-initialization.ts b/flowsint-app/src/components/sketches/graph/hooks/use-graph-initialization.ts index d8907890..3131e4cd 100644 --- a/flowsint-app/src/components/sketches/graph/hooks/use-graph-initialization.ts +++ b/flowsint-app/src/components/sketches/graph/hooks/use-graph-initialization.ts @@ -1,10 +1,21 @@ import { useEffect, useRef } from 'react' +import type { GraphViewerRef } from '@/types' + +interface GraphControlActions { + zoomIn: () => void + zoomOut: () => void + zoomToFit: () => void + zoomToSelection: () => void + centerOnNode: (x: number, y: number) => void + regenerateLayout: (layoutType: 'force' | 'hierarchy') => void + getViewportCenter: () => { x: number; y: number } | null +} interface UseGraphInitializationParams { - graphRef: React.RefObject + graphRef: React.RefObject instanceId?: string - setActions: (actions: any) => void - onGraphRef?: (ref: any) => void + setActions: (actions: GraphControlActions) => void + onGraphRef?: (ref: GraphViewerRef) => void selectedNodeIdsRef: React.RefObject> regenerateLayout: (layoutType: 'force' | 'hierarchy') => void } @@ -30,48 +41,44 @@ export const useGraphInitialization = ({ setActions({ zoomIn: () => { - if (graphRef.current && typeof graphRef.current.zoom === 'function') { + if (graphRef.current) { const zoom = graphRef.current.zoom() graphRef.current.zoom(zoom * 1.5) } }, zoomOut: () => { - if (graphRef.current && typeof graphRef.current.zoom === 'function') { + if (graphRef.current) { const zoom = graphRef.current.zoom() graphRef.current.zoom(zoom * 0.75) } }, zoomToFit: () => { - if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { - graphRef.current.zoomToFit(400) - } + graphRef.current?.zoomToFit(400) }, zoomToSelection: () => { - if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { - const nodeFilterFn = (node: any) => selectedNodeIdsRef.current?.has(node.id) + if (graphRef.current) { + const nodeFilterFn = (node: { id?: string | number }) => + node.id !== undefined && (selectedNodeIdsRef.current?.has(String(node.id)) ?? false) graphRef.current.zoomToFit(400, 50, nodeFilterFn) } }, centerOnNode: (x: number, y: number) => { - if (graphRef.current && typeof graphRef.current.centerAt === 'function') { + if (graphRef.current) { graphRef.current.centerAt(x, y, 400) - if (typeof graphRef.current.zoom === 'function') { - graphRef.current.zoom(12, 400) - } + graphRef.current.zoom(12, 400) } }, regenerateLayout: (layoutType: 'force' | 'hierarchy') => { regenerateLayoutRef.current(layoutType) }, + // BUG (pre-existing, not fixed here): ForceGraphMethods has no + // getBoundingClientRect — this always fell through to `?? {x:0,y:0}`, + // so "center of viewport" always resolved to graph-coordinate origin, + // not the actual visible center. Left as-is rather than guessing at a + // fix (needs the container's DOM rect, which this hook isn't given) + // without a way to verify the corrected behavior against the running + // app. getViewportCenter: () => { - if (!graphRef.current) return null - const rect = graphRef.current.getBoundingClientRect?.() - if (!rect) return { x: 0, y: 0 } - const screenCenterX = rect.width / 2 - const screenCenterY = rect.height / 2 - if (typeof graphRef.current.screen2GraphCoords === 'function') { - return graphRef.current.screen2GraphCoords(screenCenterX, screenCenterY) - } return { x: 0, y: 0 } } }) @@ -94,10 +101,6 @@ export const useGraphInitialization = ({ const graphInstance = graphRef.current if (!graphInstance || isGraphReadyRef.current) return - if (typeof graphInstance.zoom !== 'function' || typeof graphInstance.zoomToFit !== 'function') { - return - } - isGraphReadyRef.current = true onGraphRef?.(graphInstance) diff --git a/flowsint-app/src/components/sketches/graph/hooks/use-graph-layout.ts b/flowsint-app/src/components/sketches/graph/hooks/use-graph-layout.ts index 3e4d7f20..3bc45555 100644 --- a/flowsint-app/src/components/sketches/graph/hooks/use-graph-layout.ts +++ b/flowsint-app/src/components/sketches/graph/hooks/use-graph-layout.ts @@ -1,12 +1,15 @@ import { useState, useCallback } from 'react' import { useLayout } from '@/hooks/use-layout' +import type { GraphNode, GraphViewerRef } from '@/types' +import type { GraphForceSettings } from '@/stores/graph-settings-store' +import type { GraphData } from '../utils/types' interface UseGraphLayoutParams { - forceSettings: any + forceSettings: GraphForceSettings containerSize: { width: number; height: number } - saveAllNodePositions: (nodes: any[]) => void - graphRef: React.RefObject - graphData: any + saveAllNodePositions: (nodes: GraphNode[]) => void + graphRef: React.RefObject + graphData: GraphData } export const useGraphLayout = ({ diff --git a/flowsint-app/src/components/sketches/graph/hooks/use-highlight-state.ts b/flowsint-app/src/components/sketches/graph/hooks/use-highlight-state.ts index b3a8b3b6..c2a73c26 100644 --- a/flowsint-app/src/components/sketches/graph/hooks/use-highlight-state.ts +++ b/flowsint-app/src/components/sketches/graph/hooks/use-highlight-state.ts @@ -1,15 +1,21 @@ import { useState, useCallback, useRef, useEffect } from 'react' +import type { LinkObject } from 'react-force-graph-2d' import type { GraphNode } from '@/types' -// react-force-graph's own onLinkHover/edge.source/edge.target types are -// `string | number | NodeObject` — the library mutates a plain edge's -// source/target from an id string into the actual node object once it's -// resolved the graph, and callers see whichever form depending on timing. -// Same duality already reflected on GraphNode.links in types/graph.ts. -type HoveredLink = { source: string | { id: string }; target: string | { id: string } } +// LinkObject's own source/target type — `string | number | NodeObject` — is +// the library's real duality: a plain edge's source/target starts as an id +// and gets mutated into the actual node object once it's resolved the +// graph, and callers see whichever form depending on timing. Same duality +// already reflected on GraphNode.links in types/graph.ts. +type LinkEndpoint = LinkObject['source'] -const linkEndpointId = (endpoint: string | { id: string }): string => - typeof endpoint === 'object' ? endpoint.id : endpoint +// App ids are always strings (GraphNode.id: string) — coerce the library's +// string|number id here rather than leaking a number through to callers +// that only ever deal in string ids. +const linkEndpointId = (endpoint: LinkEndpoint): string | undefined => { + const id = typeof endpoint === 'object' ? endpoint?.id : endpoint + return id === undefined ? undefined : String(id) +} export const useHighlightState = () => { const [highlightNodes, setHighlightNodes] = useState>(new Set()) @@ -49,7 +55,7 @@ export const useHighlightState = () => { }) }, []) - const handleLinkHover = useCallback((link: HoveredLink | null) => { + const handleLinkHover = useCallback((link: LinkObject | null) => { if (hoverFrameRef.current) { cancelAnimationFrame(hoverFrameRef.current) } @@ -62,8 +68,8 @@ export const useHighlightState = () => { const sourceId = linkEndpointId(link.source) const targetId = linkEndpointId(link.target) newHighlightLinks.add(`${sourceId}-${targetId}`) - newHighlightNodes.add(sourceId) - newHighlightNodes.add(targetId) + if (sourceId !== undefined) newHighlightNodes.add(sourceId) + if (targetId !== undefined) newHighlightNodes.add(targetId) } setHoverNode(null) diff --git a/flowsint-app/src/components/sketches/graph/hooks/use-tooltip.ts b/flowsint-app/src/components/sketches/graph/hooks/use-tooltip.ts index 67d1d3cc..6001e55b 100644 --- a/flowsint-app/src/components/sketches/graph/hooks/use-tooltip.ts +++ b/flowsint-app/src/components/sketches/graph/hooks/use-tooltip.ts @@ -1,7 +1,8 @@ import { useState, useCallback } from 'react' +import type { GraphNode, GraphViewerRef } from '@/types' import { TooltipState } from '../utils/types' -export const useTooltip = (graphRef: React.RefObject) => { +export const useTooltip = (graphRef: React.RefObject) => { const [tooltip, setTooltip] = useState({ x: 0, y: 0, @@ -10,14 +11,14 @@ export const useTooltip = (graphRef: React.RefObject) => { }) const showTooltip = useCallback( - (node: any) => { + (node: GraphNode | null) => { if (!node || !graphRef.current) { setTooltip((prev) => ({ ...prev, visible: false })) return } const weight = node.neighbors?.length || 0 - const label = node.nodeLabel || node.label || node.id + const label = node.nodeLabel || node.id try { const screenCoords = graphRef.current.graph2ScreenCoords(node.x, node.y) diff --git a/flowsint-app/src/components/sketches/graph/index.tsx b/flowsint-app/src/components/sketches/graph/index.tsx index 799f4e2a..3fef3057 100644 --- a/flowsint-app/src/components/sketches/graph/index.tsx +++ b/flowsint-app/src/components/sketches/graph/index.tsx @@ -7,6 +7,8 @@ import { useGraphStore } from '@/stores/graph-store' import { useNodesDisplaySettings } from '@/stores/node-display-settings' import { useTheme } from '@/components/theme-provider' import { useSaveNodePositions } from '@/hooks/use-save-node-positions' +import type { LinkObject } from 'react-force-graph-2d' +import type { GraphViewerRef, GraphNode, GraphEdge } from '@/types' import { CONSTANTS } from './utils/constants' import { GraphViewerProps } from './utils/types' import { @@ -62,7 +64,7 @@ const GraphViewer: React.FC = ({ linkCreation: linkCreationProp }) => { const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) - const graphRef = useRef(null) + const graphRef = useRef(undefined) const containerRef = useRef(null) // Notify parent when graphRef becomes available @@ -143,7 +145,7 @@ const GraphViewer: React.FC = ({ const isSelected = useCallback((nodeId: string) => selectedNodeIds.has(nodeId), [selectedNodeIds]) - const graph2ScreenCoords = useCallback((node: any) => { + const graph2ScreenCoords = useCallback((node: GraphNode) => { if (!graphRef.current) return { x: 0, y: 0 } return graphRef.current.graph2ScreenCoords(node.x, node.y) }, []) @@ -263,8 +265,14 @@ const GraphViewer: React.FC = ({ nodeSet.add(currentNodeId) edges.forEach((edge) => { - const sourceId = typeof edge.source === 'object' ? (edge.source as any).id : edge.source - const targetId = typeof edge.target === 'object' ? (edge.target as any).id : edge.target + // GraphEdge.source/target are typed as plain id strings, but at + // runtime this array can be the same reference react-force-graph + // mutates source/target into node objects on (same duality as + // GraphNode.links — see types/graph.ts). + const sourceId = + typeof edge.source === 'object' ? (edge.source as { id: string }).id : edge.source + const targetId = + typeof edge.target === 'object' ? (edge.target as { id: string }).id : edge.target if (sourceId === currentNodeId) { nodeSet.add(targetId) linkSet.add(`${sourceId}-${targetId}`) @@ -298,7 +306,7 @@ const GraphViewer: React.FC = ({ const { tooltip, showTooltip, hideTooltip } = useTooltip(graphRef) const handleNodeHoverWithTooltip = useCallback( - (node: any) => { + (node: GraphNode | null) => { if (node) { showTooltip(node) } else { @@ -336,7 +344,7 @@ const GraphViewer: React.FC = ({ }) const wrappedHandleNodeDragEnd = useCallback( - (node: any) => { + (node: GraphNode) => { handleNodeDragEnd(node, graphData) }, [handleNodeDragEnd, graphData] @@ -360,9 +368,7 @@ const GraphViewer: React.FC = ({ }, [sketchId]) const handleZoomToFitLocal = useCallback(() => { - if (typeof graphRef.current.zoomToFit === 'function') { - graphRef.current.zoomToFit(400) - } + graphRef.current?.zoomToFit(400) }, []) useGraphInitialization({ @@ -436,7 +442,7 @@ const GraphViewer: React.FC = ({ ) const renderNodeCallback = useCallback( - (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { const rc = getOrCreateRC(globalScale) renderNode({ node, @@ -469,32 +475,29 @@ const GraphViewer: React.FC = ({ ) const renderLinkCallback = useCallback( - (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + ( + // See link-renderer.ts's LinkRenderParams for why source/target are + // omitted from the GraphEdge generic param here. + link: LinkObject>, + ctx: CanvasRenderingContext2D, + globalScale: number + ) => { const rc = getOrCreateRC(globalScale) + // theme/highlightNodes/selectedEdges aren't passed here: renderLink + // doesn't read them — theme and selection are already baked into `rc` + // (rc.themeEdgeLabelBg, rc.selectedEdgeIds) by getOrCreateRC above. renderLink({ link, ctx, globalScale, forceSettings, - theme, highlightLinks, - highlightNodes, - selectedEdges, currentEdge, autoColorLinksByNodeType, rc }) }, - [ - forceSettings, - theme, - highlightLinks, - highlightNodes, - selectedEdges, - currentEdge, - autoColorLinksByNodeType, - getOrCreateRC - ] + [forceSettings, highlightLinks, currentEdge, autoColorLinksByNodeType, getOrCreateRC] ) if (!nodes.length) { @@ -539,7 +542,7 @@ const GraphViewer: React.FC = ({ canvasHeight={containerSize.height} /> )} - ref={graphRef} width={containerSize.width} height={containerSize.height} diff --git a/flowsint-app/src/components/sketches/graph/node/node-renderer.ts b/flowsint-app/src/components/sketches/graph/node/node-renderer.ts index f998032c..45a7d93d 100644 --- a/flowsint-app/src/components/sketches/graph/node/node-renderer.ts +++ b/flowsint-app/src/components/sketches/graph/node/node-renderer.ts @@ -1,4 +1,5 @@ import { GraphNode, NodeShape } from '@/types' +import type { GraphForceSettings } from '@/stores/graph-settings-store' import { CONSTANTS, GRAPH_COLORS } from '../utils/constants' import { getCachedImage, @@ -220,7 +221,7 @@ const drawNodeLabel = ( node: GraphNode, size: number, isHighlighted: boolean, - forceSettings: any, + forceSettings: GraphForceSettings, rc: RenderContext ) => { const label = truncateText(node.nodeLabel || node.id, 58) @@ -288,7 +289,7 @@ export interface NodeRenderParams { node: GraphNode ctx: CanvasRenderingContext2D globalScale: number - forceSettings: any + forceSettings: GraphForceSettings showLabels: boolean showIcons: boolean isCurrent: (nodeId: string) => boolean @@ -313,12 +314,16 @@ const CARD_BASE = { accentWidth: 1.5 } as const -const getCardScale = (node: GraphNode, forceSettings: any) => { +const getCardScale = (node: GraphNode, forceSettings: GraphForceSettings) => { const size = calculateNodeSize(node, forceSettings, true, 1) return Math.max(0.5, size / 5) } -const getCardDimensions = (node: GraphNode, ctx: CanvasRenderingContext2D, forceSettings: any) => { +const getCardDimensions = ( + node: GraphNode, + ctx: CanvasRenderingContext2D, + forceSettings: GraphForceSettings +) => { const s = getCardScale(node, forceSettings) const label = truncateText(node.nodeLabel || node.id, 40) const typeText = node.nodeType || '' @@ -671,9 +676,9 @@ const shapeEdgeDistance = (angle: number, size: number, shape: NodeShape): numbe } export const getNodeEdgeDistance = ( - node: any, + node: GraphNode, angle: number, - forceSettings: any, + forceSettings: GraphForceSettings, ctx: CanvasRenderingContext2D, shouldRenderDetails: boolean ): number => { @@ -700,7 +705,7 @@ export const paintNodePointerArea = ( node: GraphNode, color: string, ctx: CanvasRenderingContext2D, - forceSettings: any + forceSettings: GraphForceSettings ) => { const isDotStyle = forceSettings?.dotStyle?.value ?? true diff --git a/flowsint-app/src/components/sketches/graph/utils/types.ts b/flowsint-app/src/components/sketches/graph/utils/types.ts index c4e190e5..5ac3d5fc 100644 --- a/flowsint-app/src/components/sketches/graph/utils/types.ts +++ b/flowsint-app/src/components/sketches/graph/utils/types.ts @@ -1,4 +1,4 @@ -import { GraphNode, GraphEdge } from '@/types' +import { GraphNode, GraphEdge, type GraphViewerRef } from '@/types' export interface GraphViewerProps { nodes: GraphNode[] @@ -13,7 +13,7 @@ export interface GraphViewerProps { backgroundColor?: string className?: string style?: React.CSSProperties - onGraphRef?: (ref: any) => void + onGraphRef?: (ref: GraphViewerRef) => void instanceId?: string allowLasso?: boolean sketchId?: string @@ -50,10 +50,10 @@ export interface TransformedNode extends GraphNode { nodeSize: number nodeType: string val: number - neighbors: any[] - links: any[] - fx?: number - fy?: number + // Required here (vs optional on GraphNode): transformGraphData always + // populates both before a node becomes a TransformedNode. + neighbors: NonNullable + links: NonNullable } export interface TransformedEdge extends GraphEdge { diff --git a/flowsint-app/src/components/sketches/graph/utils/utils.ts b/flowsint-app/src/components/sketches/graph/utils/utils.ts index f9c74f70..c926bebd 100644 --- a/flowsint-app/src/components/sketches/graph/utils/utils.ts +++ b/flowsint-app/src/components/sketches/graph/utils/utils.ts @@ -1,3 +1,6 @@ +import type { GraphNode } from '@/types' +import type { GraphForceSettings } from '@/stores/graph-settings-store' + export function truncateText(text: string, limit: number = 16): string { if (text.length <= limit) return text return text.substring(0, limit) + '...' @@ -14,8 +17,8 @@ export function truncateText(text: string, limit: number = 16): string { * @returns The calculated node radius */ export function calculateNodeSize( - node: any, - forceSettings: any, + node: GraphNode, + forceSettings: GraphForceSettings, shouldRenderDetails: boolean, zoomedOutMultiplier: number = 0.6 ): number { diff --git a/flowsint-app/src/components/table/nodes-view.tsx b/flowsint-app/src/components/table/nodes-view.tsx index a9bcb43e..ed932cc2 100644 --- a/flowsint-app/src/components/table/nodes-view.tsx +++ b/flowsint-app/src/components/table/nodes-view.tsx @@ -309,7 +309,8 @@ export default function NodesTable({ nodes }: NodesTableProps) { checked={isAllSelected} ref={(el) => { if (el && 'indeterminate' in el) { - ;(el as HTMLInputElement).indeterminate = isIndeterminate + const input = el as HTMLInputElement + input.indeterminate = isIndeterminate } }} onCheckedChange={handleSelectAll} diff --git a/flowsint-app/src/components/table/relationships-view.tsx b/flowsint-app/src/components/table/relationships-view.tsx index b6dc8603..671d4aa9 100644 --- a/flowsint-app/src/components/table/relationships-view.tsx +++ b/flowsint-app/src/components/table/relationships-view.tsx @@ -409,7 +409,8 @@ export default function RelationshipsTable() { checked={isAllSelected} ref={(el) => { if (el && 'indeterminate' in el) { - ;(el as HTMLInputElement).indeterminate = isIndeterminate + const input = el as HTMLInputElement + input.indeterminate = isIndeterminate } }} onCheckedChange={handleSelectAll} diff --git a/flowsint-app/src/hooks/use-layout.ts b/flowsint-app/src/hooks/use-layout.ts index d79ee84e..03643bff 100644 --- a/flowsint-app/src/hooks/use-layout.ts +++ b/flowsint-app/src/hooks/use-layout.ts @@ -1,10 +1,11 @@ import { useCallback, useRef, useEffect } from 'react' import { GraphEdge, GraphNode } from '@/types' +import type { GraphForceSettings } from '@/stores/graph-settings-store' interface UseLayoutProps { - forceSettings: any + forceSettings: GraphForceSettings containerSize: { width: number; height: number } - saveAllNodePositions: (nodes: any[], force?: boolean) => void + saveAllNodePositions: (nodes: GraphNode[], force?: boolean) => void onProgress?: (progress: number) => void } @@ -39,7 +40,7 @@ export function useLayout({ throw new Error('Layout worker not initialized') } // Remove fx and fy from all nodes to allow repositioning - nodes.forEach((node: any) => { + nodes.forEach((node) => { delete node.fx delete node.fy }) @@ -50,10 +51,14 @@ export function useLayout({ if (event.data.type === 'progress') { onProgress?.(event.data.progress) } else if (event.data.type === 'complete') { - const { nodes: layoutedNodes } = event.data.result + // Worker result crosses postMessage's structured-clone boundary — + // only .id/.x/.y are ever read off it, so that's all it declares. + const { nodes: layoutedNodes } = event.data.result as { + nodes: { id: string; x?: number; y?: number }[] + } // // Apply the calculated positions to the graph nodes - layoutedNodes.forEach((layoutedNode: any) => { - const graphNode = nodes.find((n: any) => n.id === layoutedNode.id) as any + layoutedNodes.forEach((layoutedNode) => { + const graphNode = nodes.find((n) => n.id === layoutedNode.id) if (graphNode && layoutedNode.x !== undefined && layoutedNode.y !== undefined) { graphNode.x = layoutedNode.x graphNode.y = layoutedNode.y diff --git a/flowsint-app/src/types/graph.ts b/flowsint-app/src/types/graph.ts index 73fd3757..aba2b8a5 100644 --- a/flowsint-app/src/types/graph.ts +++ b/flowsint-app/src/types/graph.ts @@ -1,4 +1,5 @@ import type * as LucideIcons from 'lucide-react' +import type { ForceGraphMethods, NodeObject, LinkObject } from 'react-force-graph-2d' // Field shape is genuinely dynamic — driven by whichever custom type schema // the backend defines for this node's nodeType, not a fixed TS shape. Callers @@ -40,6 +41,13 @@ export type GraphNode = { x: number y: number val?: number + // Pinning coordinates react-force-graph itself reads/writes (its own + // NodeObject type declares these) to freeze a node's position instead of + // letting the force simulation keep moving it — set after drag-end and + // after layout runs. Not part of the backend payload; only ever present + // once the graph has rendered at least once. + fx?: number + fy?: number // Populated by transformGraphData (graph-data-transformer.ts) after the // initial fetch — every consumer only ever reads .id off a neighbor and // .source.id/.target.id off a link, so that's all this declares. Not @@ -67,6 +75,17 @@ export type GraphEdge = { confidence_level?: number | string } +// The imperative handle exposed by +// ref={...} />. Every call site across the graph subsystem (index.tsx, +// graph-main.tsx, the layout/tooltip/minimap hooks) holds a ref to the same +// underlying instance and used to type it `React.RefObject` — one +// shared alias instead of re-guessing the shape at each site. Parametrized +// with GraphNode/GraphEdge to match the concrete generic instantiation used +// where is actually rendered. +export type GraphViewerRef = + | ForceGraphMethods, LinkObject> + | undefined + export type PathNode = { id: string label: string