mirror of
https://github.com/reconurge/flowsint.git
synced 2026-11-08 14:35:41 -06:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user