fix(frontend): type the graph subsystem's react-force-graph boundary

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<NodeObject<GraphNode>,
  LinkObject<GraphNode, GraphEdge>> | undefined — the one ref type
  every graphRef across 8 files now uses instead of re-guessing
  `React.RefObject<any>` 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
`<ForceGraph2D<GraphNode, GraphEdge>>` (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<GraphNode,
Omit<GraphEdge, 'source'|'target'>>` 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.
This commit is contained in:
dextmorgn
2026-08-25 09:33:35 +02:00
parent 203d6c1363
commit e3014553a5
17 changed files with 181 additions and 123 deletions
@@ -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<any>
graphRef: React.RefObject<GraphViewerRef>
/** Canvas width */
canvasWidth: number
/** Canvas height */
@@ -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<any>(null)
const graphRef = useRef<GraphViewerRef>(undefined)
const containerRef = useRef<HTMLDivElement>(null)
const [nodeMenu, setNodeMenu] = React.useState<NodeContextMenuProps | null>(null)
const [edgeMenu, setEdgeMenu] = React.useState<EdgeContextMenuProps | null>(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
}, [])
@@ -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<any>
graphRef: React.RefObject<GraphViewerRef>
canvasWidth: number
canvasHeight: number
}
@@ -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<GraphNode, GraphEdge> is what <ForceGraph2D<GraphNode,
// GraphEdge>> 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<NodeType>` 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<GraphNode, Omit<GraphEdge, 'source' | 'target'>>
ctx: CanvasRenderingContext2D
globalScale: number
forceSettings: any
theme: string
forceSettings: GraphForceSettings
highlightLinks: Set<string>
highlightNodes: Set<string>
selectedEdges: any[]
currentEdge: any
currentEdge: GraphEdge | null | undefined
autoColorLinksByNodeType?: boolean
rc: RenderContext
}
@@ -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<any>
graphRef: React.RefObject<GraphViewerRef>
edgeMap: Map<string, GraphEdge>
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
@@ -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<any>
graphRef: React.RefObject<GraphViewerRef>
instanceId?: string
setActions: (actions: any) => void
onGraphRef?: (ref: any) => void
setActions: (actions: GraphControlActions) => void
onGraphRef?: (ref: GraphViewerRef) => void
selectedNodeIdsRef: React.RefObject<Set<string>>
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)
@@ -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<any>
graphData: any
saveAllNodePositions: (nodes: GraphNode[]) => void
graphRef: React.RefObject<GraphViewerRef>
graphData: GraphData
}
export const useGraphLayout = ({
@@ -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<Set<string>>(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)
@@ -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<any>) => {
export const useTooltip = (graphRef: React.RefObject<GraphViewerRef>) => {
const [tooltip, setTooltip] = useState<TooltipState>({
x: 0,
y: 0,
@@ -10,14 +11,14 @@ export const useTooltip = (graphRef: React.RefObject<any>) => {
})
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)
@@ -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<GraphViewerProps> = ({
linkCreation: linkCreationProp
}) => {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const graphRef = useRef<any>(null)
const graphRef = useRef<GraphViewerRef>(undefined)
const containerRef = useRef<HTMLDivElement>(null)
// Notify parent when graphRef becomes available
@@ -143,7 +145,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
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<GraphViewerProps> = ({
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<GraphViewerProps> = ({
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<GraphViewerProps> = ({
})
const wrappedHandleNodeDragEnd = useCallback(
(node: any) => {
(node: GraphNode) => {
handleNodeDragEnd(node, graphData)
},
[handleNodeDragEnd, graphData]
@@ -360,9 +368,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
}, [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<GraphViewerProps> = ({
)
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<GraphViewerProps> = ({
)
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<GraphNode, Omit<GraphEdge, 'source' | 'target'>>,
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<GraphViewerProps> = ({
canvasHeight={containerSize.height}
/>
)}
<ForceGraph2D
<ForceGraph2D<GraphNode, GraphEdge>
ref={graphRef}
width={containerSize.width}
height={containerSize.height}
@@ -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
@@ -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<GraphNode['neighbors']>
links: NonNullable<GraphNode['links']>
}
export interface TransformedEdge extends GraphEdge {
@@ -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 {
@@ -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}
@@ -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}
+11 -6
View File
@@ -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
+19
View File
@@ -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 <ForceGraph2D<GraphNode, GraphEdge>
// 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<any>` — one
// shared alias instead of re-guessing the shape at each site. Parametrized
// with GraphNode/GraphEdge to match the concrete generic instantiation used
// where <ForceGraph2D> is actually rendered.
export type GraphViewerRef =
| ForceGraphMethods<NodeObject<GraphNode>, LinkObject<GraphNode, GraphEdge>>
| undefined
export type PathNode = {
id: string
label: string