feat(app): improve node and edge rendering

This commit is contained in:
dextmorgn
2026-03-08 20:01:55 +01:00
parent a2cc352235
commit 611bc1a563
6 changed files with 863 additions and 412 deletions

BIN
flowsint-app/.DS_Store vendored

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import { CONSTANTS, GRAPH_COLORS, tempPos, tempDimensions } from '../utils/constants' import { CONSTANTS, GRAPH_COLORS, tempPos, tempDimensions } from '../utils/constants'
import { calculateNodeSize } from '../utils/utils' import { getNodeEdgeDistance } from '../node/node-renderer'
import { RenderContext, isEdgeInViewport } from '../utils/render-context'
interface LinkRenderParams { interface LinkRenderParams {
link: any link: any
@@ -12,69 +13,26 @@ interface LinkRenderParams {
selectedEdges: any[] selectedEdges: any[]
currentEdge: any currentEdge: any
autoColorLinksByNodeType?: boolean autoColorLinksByNodeType?: boolean
rc: RenderContext
} }
// Helper to check if a node position is in viewport // Module-level bezier tangent to avoid closure allocation per link
const isPositionInViewport = ( const bezierTangentAt1 = (
x: number, startX: number,
y: number, startY: number,
ctx: CanvasRenderingContext2D, ctrlX: number,
margin: number = 80 ctrlY: number,
): boolean => { endX: number,
const transform = ctx.getTransform() endY: number,
const canvasWidth = ctx.canvas.width isCurved: boolean
const canvasHeight = ctx.canvas.height ) => {
if (!isCurved) {
// Transform position to screen coordinates return { x: endX - startX, y: endY - startY }
const screenX = x * transform.a + transform.e }
const screenY = y * transform.d + transform.f return {
x: 2 * (endX - ctrlX),
// Check if within viewport bounds (with margin for smoother culling) y: 2 * (endY - ctrlY)
return ( }
screenX >= -margin &&
screenX <= canvasWidth + margin &&
screenY >= -margin &&
screenY <= canvasHeight + margin
)
}
// Helper to check if edge is in viewport (at least one endpoint or edge crosses viewport)
const isEdgeInViewport = (
link: any,
ctx: CanvasRenderingContext2D,
margin: number = 80
): boolean => {
const { source: start, target: end } = link
if (typeof start !== 'object' || typeof end !== 'object') return false
// Check if either endpoint is in viewport
const startInView = isPositionInViewport(start.x, start.y, ctx, margin)
const endInView = isPositionInViewport(end.x, end.y, ctx, margin)
if (startInView || endInView) return true
// Check if edge crosses viewport (both endpoints outside but line passes through)
const transform = ctx.getTransform()
const canvasWidth = ctx.canvas.width
const canvasHeight = ctx.canvas.height
const screenStartX = start.x * transform.a + transform.e
const screenStartY = start.y * transform.d + transform.f
const screenEndX = end.x * transform.a + transform.e
const screenEndY = end.y * transform.d + transform.f
// Simple AABB intersection check
const minX = Math.min(screenStartX, screenEndX)
const maxX = Math.max(screenStartX, screenEndX)
const minY = Math.min(screenStartY, screenEndY)
const maxY = Math.max(screenStartY, screenEndY)
return !(
maxX < -margin ||
minX > canvasWidth + margin ||
maxY < -margin ||
minY > canvasHeight + margin
)
} }
export const renderLink = ({ export const renderLink = ({
@@ -82,30 +40,26 @@ export const renderLink = ({
ctx, ctx,
globalScale, globalScale,
forceSettings, forceSettings,
theme,
highlightLinks, highlightLinks,
highlightNodes,
selectedEdges,
currentEdge, currentEdge,
autoColorLinksByNodeType autoColorLinksByNodeType,
rc
}: LinkRenderParams) => { }: LinkRenderParams) => {
if (globalScale < CONSTANTS.ZOOM_EDGE_DETAIL_THRESHOLD) return if (globalScale < CONSTANTS.ZOOM_EDGE_DETAIL_THRESHOLD) return
const { source: start, target: end } = link const { source: start, target: end } = link
if (typeof start !== 'object' || typeof end !== 'object') return if (typeof start !== 'object' || typeof end !== 'object') return
// Early exit: skip edge if outside viewport // Viewport culling using pre-computed transform (no DOMMatrix allocation)
if (!isEdgeInViewport(link, ctx)) return if (!isEdgeInViewport(start.x, start.y, end.x, end.y, ctx)) return
const linkKey = `${start.id}-${end.id}` const linkKey = `${start.id}-${end.id}`
const isHighlighted = highlightLinks.has(linkKey) const isHighlighted = highlightLinks.has(linkKey)
const isSelected = selectedEdges.some((e) => e.id === link.id) const isSelected = rc.selectedEdgeIds.has(link.id)
const isCurrent = currentEdge?.id === link.id const isCurrent = currentEdge?.id === link.id
const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0
let linkWidthBase = forceSettings?.linkWidth?.value ?? 2 let linkWidthBase = forceSettings?.linkWidth?.value ?? 2
const shouldRenderDetails = globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD
const linkWidth = shouldRenderDetails const linkWidth = rc.shouldRenderDetails
? linkWidthBase ? linkWidthBase
: linkWidthBase * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER : linkWidthBase * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
@@ -114,76 +68,86 @@ export const renderLink = ({
: GRAPH_COLORS.LINK_DEFAULT : GRAPH_COLORS.LINK_DEFAULT
let strokeStyle: string let strokeStyle: string
let lineWidth: number
let fillStyle: string let fillStyle: string
let lineWidth: number
if (isCurrent) { if (isCurrent) {
strokeStyle = 'rgba(59, 130, 246, 0.95)' strokeStyle = 'rgba(59, 130, 246, 0.95)'
fillStyle = 'rgba(59, 130, 246, 0.95)' fillStyle = strokeStyle
lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 2.3) lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 2.3)
} else if (isSelected) { } else if (isSelected) {
strokeStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_HIGHLIGHTED strokeStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_HIGHLIGHTED
fillStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_HIGHLIGHTED fillStyle = strokeStyle
lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 2.5) lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 2.5)
} else if (isHighlighted) { } else if (isHighlighted) {
strokeStyle = GRAPH_COLORS.LINK_HIGHLIGHTED strokeStyle = GRAPH_COLORS.LINK_HIGHLIGHTED
fillStyle = GRAPH_COLORS.LINK_HIGHLIGHTED fillStyle = strokeStyle
lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 3) lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 3)
} else if (hasAnyHighlight) { } else if (rc.hasAnyHighlight) {
strokeStyle = GRAPH_COLORS.LINK_DIMMED strokeStyle = GRAPH_COLORS.LINK_DIMMED
fillStyle = GRAPH_COLORS.LINK_DIMMED fillStyle = strokeStyle
lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 5) lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 5)
} else { } else {
strokeStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_DEFAULT strokeStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_DEFAULT
fillStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_DEFAULT fillStyle = strokeStyle
lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 5) lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 5)
} }
// Calculate node radii to stop links at node edges
// Uses the shared calculateNodeSize function to ensure consistency with node-renderer
const startRadius = calculateNodeSize(
start,
forceSettings,
shouldRenderDetails,
CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
)
const endRadius = calculateNodeSize(
end,
forceSettings,
shouldRenderDetails,
CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
)
const arrowLengthSetting = forceSettings?.linkDirectionalArrowLength?.value const arrowLengthSetting = forceSettings?.linkDirectionalArrowLength?.value
const arrowLength = shouldRenderDetails const arrowLength = rc.shouldRenderDetails
? arrowLengthSetting ? arrowLengthSetting
: arrowLengthSetting * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER : arrowLengthSetting * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
// Draw connection line // Geometry
const curvature: number = link.curvature || 0 const curvature: number = link.curvature || 0
const dx = end.x - start.x const dx = end.x - start.x
const dy = end.y - start.y const dy = end.y - start.y
const distance = Math.sqrt(dx * dx + dy * dy) || 1 const distance = Math.sqrt(dx * dx + dy * dy) || 1
// Shorten the line to stop at node edges const origMidX = (start.x + end.x) * 0.5
const startRatio = startRadius / distance const origMidY = (start.y + end.y) * 0.5
const endRatio = (endRadius + arrowLength) / distance
const adjustedStartX = start.x + dx * startRatio
const adjustedStartY = start.y + dy * startRatio
const adjustedEndX = end.x - dx * endRatio
const adjustedEndY = end.y - dy * endRatio
const midX = (adjustedStartX + adjustedEndX) * 0.5
const midY = (adjustedStartY + adjustedEndY) * 0.5
const normX = -dy / distance const normX = -dy / distance
const normY = dx / distance const normY = dx / distance
const offset = curvature * distance const offset = curvature * distance
const ctrlX = midX + normX * offset const ctrlX = origMidX + normX * offset
const ctrlY = midY + normY * offset const ctrlY = origMidY + normY * offset
const isCurved = curvature !== 0
const startTanX = isCurved ? ctrlX - start.x : dx
const startTanY = isCurved ? ctrlY - start.y : dy
const startTanLen = Math.hypot(startTanX, startTanY) || 1
const endTanX = isCurved ? end.x - ctrlX : dx
const endTanY = isCurved ? end.y - ctrlY : dy
const endTanLen = Math.hypot(endTanX, endTanY) || 1
const startAngle = Math.atan2(startTanY, startTanX)
const endAngle = Math.atan2(endTanY, endTanX)
const startEdgeDist = getNodeEdgeDistance(
start,
startAngle,
forceSettings,
ctx,
rc.shouldRenderDetails
)
const endEdgeDist = getNodeEdgeDistance(
end,
endAngle + Math.PI,
forceSettings,
ctx,
rc.shouldRenderDetails
)
const adjustedStartX = start.x + (startTanX / startTanLen) * startEdgeDist
const adjustedStartY = start.y + (startTanY / startTanLen) * startEdgeDist
const adjustedEndX = end.x - (endTanX / endTanLen) * (endEdgeDist + arrowLength)
const adjustedEndY = end.y - (endTanY / endTanLen) * (endEdgeDist + arrowLength)
// Draw line
ctx.beginPath() ctx.beginPath()
ctx.moveTo(adjustedStartX, adjustedStartY) ctx.moveTo(adjustedStartX, adjustedStartY)
if (curvature !== 0) { if (isCurved) {
ctx.quadraticCurveTo(ctrlX, ctrlY, adjustedEndX, adjustedEndY) ctx.quadraticCurveTo(ctrlX, ctrlY, adjustedEndX, adjustedEndY)
} else { } else {
ctx.lineTo(adjustedEndX, adjustedEndY) ctx.lineTo(adjustedEndX, adjustedEndY)
@@ -192,59 +156,23 @@ export const renderLink = ({
ctx.lineWidth = lineWidth ctx.lineWidth = lineWidth
ctx.stroke() ctx.stroke()
// Draw directional arrow // Arrow
if (arrowLength && arrowLength > 0) { if (arrowLength && arrowLength > 0) {
const arrowRelPos = forceSettings?.linkDirectionalArrowRelPos?.value || 1 const endTan = bezierTangentAt1(
adjustedStartX, adjustedStartY,
const bezierPoint = (t: number) => { ctrlX, ctrlY,
if (curvature === 0) { adjustedEndX, adjustedEndY,
return { x: start.x + dx * t, y: start.y + dy * t } isCurved
} )
const oneMinusT = 1 - t const angle = Math.atan2(endTan.y, endTan.x)
return {
x: oneMinusT * oneMinusT * start.x + 2 * oneMinusT * t * ctrlX + t * t * end.x,
y: oneMinusT * oneMinusT * start.y + 2 * oneMinusT * t * ctrlY + t * t * end.y
}
}
const bezierTangent = (t: number) => {
if (curvature === 0) {
return { x: dx, y: dy }
}
const oneMinusT = 1 - t
return {
x: 2 * oneMinusT * (ctrlX - start.x) + 2 * t * (end.x - ctrlX),
y: 2 * oneMinusT * (ctrlY - start.y) + 2 * t * (end.y - ctrlY)
}
}
const t = arrowRelPos
let { x: arrowX, y: arrowY } = bezierPoint(t)
if (arrowRelPos === 1) {
const tan = bezierTangent(0.99)
const tanLen = Math.hypot(tan.x, tan.y) || 1
// Use the same calculation as above to ensure consistency
const targetNodeSize = calculateNodeSize(
end,
forceSettings,
shouldRenderDetails,
CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
)
arrowX = end.x - (tan.x / tanLen) * targetNodeSize
arrowY = end.y - (tan.y / tanLen) * targetNodeSize
}
const tan = bezierTangent(t)
const angle = Math.atan2(tan.y, tan.x)
ctx.save() ctx.save()
ctx.translate(arrowX, arrowY) ctx.translate(adjustedEndX, adjustedEndY)
ctx.rotate(angle) ctx.rotate(angle)
ctx.beginPath() ctx.beginPath()
ctx.moveTo(0, 0) ctx.moveTo(arrowLength, 0)
ctx.lineTo(-arrowLength, -arrowLength * 0.5) ctx.lineTo(0, -arrowLength * 0.5)
ctx.lineTo(-arrowLength, arrowLength * 0.5) ctx.lineTo(0, arrowLength * 0.5)
ctx.closePath() ctx.closePath()
ctx.fillStyle = fillStyle ctx.fillStyle = fillStyle
ctx.fill() ctx.fill()
@@ -253,22 +181,24 @@ export const renderLink = ({
if (!link.label) return if (!link.label) return
// Draw label for highlighted links // Label (only for highlighted links when zoomed in)
if (isHighlighted && globalScale > CONSTANTS.ZOOM_EDGE_DETAIL_THRESHOLD) { if (isHighlighted && globalScale > CONSTANTS.ZOOM_EDGE_DETAIL_THRESHOLD) {
let textAngle: number let textAngle: number
if ((link.curvature || 0) !== 0) { if (isCurved) {
const t = 0.5 const t = 0.5
const oneMinusT = 1 - t const oneMinusT = 0.5
tempPos.x = oneMinusT * oneMinusT * start.x + 2 * oneMinusT * t * ctrlX + t * t * end.x tempPos.x =
tempPos.y = oneMinusT * oneMinusT * start.y + 2 * oneMinusT * t * ctrlY + t * t * end.y oneMinusT * oneMinusT * adjustedStartX + 2 * oneMinusT * t * ctrlX + t * t * adjustedEndX
const tx = 2 * oneMinusT * (ctrlX - start.x) + 2 * t * (end.x - ctrlX) tempPos.y =
const ty = 2 * oneMinusT * (ctrlY - start.y) + 2 * t * (end.y - ctrlY) oneMinusT * oneMinusT * adjustedStartY + 2 * oneMinusT * t * ctrlY + t * t * adjustedEndY
const tx = 2 * oneMinusT * (ctrlX - adjustedStartX) + 2 * t * (adjustedEndX - ctrlX)
const ty = 2 * oneMinusT * (ctrlY - adjustedStartY) + 2 * t * (adjustedEndY - ctrlY)
textAngle = Math.atan2(ty, tx) textAngle = Math.atan2(ty, tx)
} else { } else {
tempPos.x = (start.x + end.x) * 0.5 tempPos.x = (adjustedStartX + adjustedEndX) * 0.5
tempPos.y = (start.y + end.y) * 0.5 tempPos.y = (adjustedStartY + adjustedEndY) * 0.5
const sdx = end.x - start.x const sdx = adjustedEndX - adjustedStartX
const sdy = end.y - start.y const sdy = adjustedEndY - adjustedStartY
textAngle = Math.atan2(sdy, sdx) textAngle = Math.atan2(sdy, sdx)
} }
@@ -279,7 +209,10 @@ export const renderLink = ({
const linkLabelSetting = forceSettings?.linkLabelFontSize?.value ?? 50 const linkLabelSetting = forceSettings?.linkLabelFontSize?.value ?? 50
const linkFontSize = CONSTANTS.LABEL_FONT_SIZE * (linkLabelSetting / 100) const linkFontSize = CONSTANTS.LABEL_FONT_SIZE * (linkLabelSetting / 100)
ctx.font = `${linkFontSize}px Sans-Serif` ctx.font = `${linkFontSize}px Sans-Serif`
const textWidth = ctx.measureText(link.label).width
// Single measureText call — reuse metrics for both width and vertical positioning
const metrics = ctx.measureText(link.label)
const textWidth = metrics.width
const padding = linkFontSize * CONSTANTS.PADDING_RATIO const padding = linkFontSize * CONSTANTS.PADDING_RATIO
tempDimensions[0] = textWidth + padding tempDimensions[0] = textWidth + padding
tempDimensions[1] = linkFontSize + padding tempDimensions[1] = linkFontSize + padding
@@ -294,14 +227,10 @@ export const renderLink = ({
ctx.beginPath() ctx.beginPath()
ctx.roundRect(-halfWidth, -halfHeight, tempDimensions[0], tempDimensions[1], borderRadius) ctx.roundRect(-halfWidth, -halfHeight, tempDimensions[0], tempDimensions[1], borderRadius)
if (theme === 'light') { ctx.fillStyle = rc.themeEdgeLabelBg
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'
} else {
ctx.fillStyle = 'rgba(32, 32, 32, 0.95)'
}
ctx.fill() ctx.fill()
ctx.strokeStyle = theme === 'light' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)' ctx.strokeStyle = rc.themeLabelBorder
ctx.lineWidth = 0.1 ctx.lineWidth = 0.1
ctx.stroke() ctx.stroke()
@@ -309,12 +238,10 @@ export const renderLink = ({
? GRAPH_COLORS.LINK_LABEL_HIGHLIGHTED ? GRAPH_COLORS.LINK_LABEL_HIGHLIGHTED
: GRAPH_COLORS.LINK_LABEL_DEFAULT : GRAPH_COLORS.LINK_LABEL_DEFAULT
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic' // More consistent across browsers than 'middle' ctx.textBaseline = 'alphabetic'
// Calculate vertical center manually using font metrics
const labelMetrics = ctx.measureText(link.label)
const labelTextY = const labelTextY =
labelMetrics.actualBoundingBoxAscent * 0.5 - labelMetrics.actualBoundingBoxDescent * 0.5 metrics.actualBoundingBoxAscent * 0.5 - metrics.actualBoundingBoxDescent * 0.5
ctx.fillText(link.label, 0, labelTextY) ctx.fillText(link.label, 0, labelTextY)
ctx.restore() ctx.restore()

View File

@@ -18,6 +18,7 @@ import {
import { renderNode } from './node/node-renderer' import { renderNode } from './node/node-renderer'
import { useKeyboardEvents } from './hooks/use-keyboard-events' import { useKeyboardEvents } from './hooks/use-keyboard-events'
import { renderLink } from './edge/link-renderer' import { renderLink } from './edge/link-renderer'
import { createRenderContext, RenderContext } from './utils/render-context'
import { useHighlightState } from './hooks/use-highlight-state' import { useHighlightState } from './hooks/use-highlight-state'
import { useComputedHighlights } from './hooks/use-computed-highlights' import { useComputedHighlights } from './hooks/use-computed-highlights'
import { useTooltip } from './hooks/use-tooltip' import { useTooltip } from './hooks/use-tooltip'
@@ -368,8 +369,42 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
setImportModalOpen(true) setImportModalOpen(true)
}, [setImportModalOpen]) }, [setImportModalOpen])
// Render context: created once per frame, shared across all node/link render calls.
// Invalidated when dependencies change or when the canvas transform changes between frames.
const rcRef = useRef<{ rc: RenderContext | null; depsVersion: number; frameKey: string }>({
rc: null,
depsVersion: 0,
frameKey: ''
})
// Increment version whenever render context deps change to force recreation
const rcDepsVersion = useMemo(() => {
rcRef.current.depsVersion++
return rcRef.current.depsVersion
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlightNodes, highlightLinks, selectedEdges, theme])
const getOrCreateRC = useCallback(
(globalScale: number): RenderContext => {
const frameKey = `${globalScale}:${rcDepsVersion}`
if (rcRef.current.frameKey !== frameKey || !rcRef.current.rc) {
rcRef.current.rc = createRenderContext(
globalScale,
highlightNodes,
highlightLinks,
selectedEdges,
theme
)
rcRef.current.frameKey = frameKey
}
return rcRef.current.rc
},
[highlightNodes, highlightLinks, selectedEdges, theme, rcDepsVersion]
)
const renderNodeCallback = useCallback( const renderNodeCallback = useCallback(
(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const rc = getOrCreateRC(globalScale)
renderNode({ renderNode({
node, node,
ctx, ctx,
@@ -382,7 +417,8 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
theme, theme,
highlightNodes, highlightNodes,
highlightLinks, highlightLinks,
hoverNode hoverNode,
rc
}) })
}, },
[ [
@@ -394,12 +430,14 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
theme, theme,
highlightNodes, highlightNodes,
highlightLinks, highlightLinks,
hoverNode hoverNode,
getOrCreateRC
] ]
) )
const renderLinkCallback = useCallback( const renderLinkCallback = useCallback(
(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const rc = getOrCreateRC(globalScale)
renderLink({ renderLink({
link, link,
ctx, ctx,
@@ -410,7 +448,8 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
highlightNodes, highlightNodes,
selectedEdges, selectedEdges,
currentEdge, currentEdge,
autoColorLinksByNodeType autoColorLinksByNodeType,
rc
}) })
}, },
[ [
@@ -420,7 +459,8 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
highlightNodes, highlightNodes,
selectedEdges, selectedEdges,
currentEdge, currentEdge,
autoColorLinksByNodeType autoColorLinksByNodeType,
getOrCreateRC
] ]
) )

View File

@@ -7,37 +7,47 @@ import {
getCachedExternalImage getCachedExternalImage
} from '../utils/image-cache' } from '../utils/image-cache'
import { truncateText, calculateNodeSize } from '../utils/utils' import { truncateText, calculateNodeSize } from '../utils/utils'
import { RenderContext, isInViewport, getDimmedColor } from '../utils/render-context'
type NodeVisual = { type NodeVisual = {
image: HTMLImageElement image: HTMLImageElement
isExternal: boolean isExternal: boolean
} | null } | null
// Shape path helpers - each creates a path centered at (x, y) with given size const FLAG_COLORS: Record<string, { stroke: string; fill: string }> = {
red: { stroke: '#f87171', fill: '#fecaca' },
orange: { stroke: '#fb923c', fill: '#fed7aa' },
blue: { stroke: '#60a5fa', fill: '#bfdbfe' },
green: { stroke: '#4ade80', fill: '#bbf7d0' },
yellow: { stroke: '#facc15', fill: '#fef08a' }
}
// --- Shape path helpers ---
const drawCirclePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { const drawCirclePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => {
ctx.arc(x, y, size, 0, 2 * Math.PI) ctx.arc(x, y, size, 0, 2 * Math.PI)
} }
const drawSquarePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { const drawSquarePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => {
const half = size ctx.rect(x - size, y - size, size * 2, size * 2)
ctx.rect(x - half, y - half, half * 2, half * 2)
} }
// Pre-computed hexagon angles (flat-top)
const HEX_COS = Array.from({ length: 6 }, (_, i) => Math.cos((Math.PI / 3) * i - Math.PI / 6))
const HEX_SIN = Array.from({ length: 6 }, (_, i) => Math.sin((Math.PI / 3) * i - Math.PI / 6))
const drawHexagonPath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { const drawHexagonPath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => {
// Flat-top hexagon ctx.moveTo(x + size * HEX_COS[0], y + size * HEX_SIN[0])
for (let i = 0; i < 6; i++) { for (let i = 1; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6 ctx.lineTo(x + size * HEX_COS[i], y + size * HEX_SIN[i])
const px = x + size * Math.cos(angle)
const py = y + size * Math.sin(angle)
if (i === 0) ctx.moveTo(px, py)
else ctx.lineTo(px, py)
} }
ctx.closePath() ctx.closePath()
} }
const SQRT3 = Math.sqrt(3)
const drawTrianglePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { const drawTrianglePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => {
// Equilateral triangle pointing up const height = size * SQRT3
const height = size * Math.sqrt(3)
const topY = y - height / 2 const topY = y - height / 2
const bottomY = y + height / 2 const bottomY = y + height / 2
ctx.moveTo(x, topY) ctx.moveTo(x, topY)
@@ -46,7 +56,6 @@ const drawTrianglePath = (ctx: CanvasRenderingContext2D, x: number, y: number, s
ctx.closePath() ctx.closePath()
} }
// Unified shape path drawer
const drawNodePath = ( const drawNodePath = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x: number, x: number,
@@ -72,7 +81,8 @@ const drawNodePath = (
} }
} }
// Draws an image with object-cover behavior inside a shape clip // --- Clipped image drawing ---
const drawClippedImage = ( const drawClippedImage = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
img: HTMLImageElement, img: HTMLImageElement,
@@ -84,14 +94,11 @@ const drawClippedImage = (
const diameter = size * 2 const diameter = size * 2
const imgAspect = img.naturalWidth / img.naturalHeight const imgAspect = img.naturalWidth / img.naturalHeight
// Calculate cover dimensions (fill the shape, crop excess)
let drawWidth: number, drawHeight: number let drawWidth: number, drawHeight: number
if (imgAspect > 1) { if (imgAspect > 1) {
// Landscape: height fills, width overflows
drawHeight = diameter drawHeight = diameter
drawWidth = diameter * imgAspect drawWidth = diameter * imgAspect
} else { } else {
// Portrait or square: width fills, height overflows
drawWidth = diameter drawWidth = diameter
drawHeight = diameter / imgAspect drawHeight = diameter / imgAspect
} }
@@ -101,21 +108,19 @@ const drawClippedImage = (
ctx.drawImage(img, x - drawWidth / 2, y - drawHeight / 2, drawWidth, drawHeight) ctx.drawImage(img, x - drawWidth / 2, y - drawHeight / 2, drawWidth, drawHeight)
} }
// Resolves the visual for a node with priority: nodeImage -> nodeIcon -> nodeType // --- Node visual resolution ---
const getNodeVisual = (node: GraphNode, iconColor: string): NodeVisual => { const getNodeVisual = (node: GraphNode, iconColor: string): NodeVisual => {
// Priority 1: External image (nodeImage URL)
if (node.nodeImage) { if (node.nodeImage) {
const img = getCachedExternalImage(node.nodeImage) const img = getCachedExternalImage(node.nodeImage)
if (img?.complete) return { image: img, isExternal: true } if (img?.complete) return { image: img, isExternal: true }
} }
// Priority 2: Custom icon by name (nodeIcon)
if (node.nodeIcon) { if (node.nodeIcon) {
const img = getCachedIconByName(node.nodeIcon, iconColor) const img = getCachedIconByName(node.nodeIcon, iconColor)
if (img?.complete) return { image: img, isExternal: false } if (img?.complete) return { image: img, isExternal: false }
} }
// Priority 3: Type-based icon (nodeType)
if (node.nodeType) { if (node.nodeType) {
const img = getCachedImage(node.nodeType, iconColor) const img = getCachedImage(node.nodeType, iconColor)
if (img?.complete) return { image: img, isExternal: false } if (img?.complete) return { image: img, isExternal: false }
@@ -124,7 +129,164 @@ const getNodeVisual = (node: GraphNode, iconColor: string): NodeVisual => {
return null return null
} }
interface NodeRenderParams { // --- Flag drawing (single shared helper) ---
const drawFlag = (
ctx: CanvasRenderingContext2D,
node: GraphNode,
size: number
) => {
const flagColor = FLAG_COLORS[node.nodeFlag!]
if (!flagColor) return
const cachedFlag = getCachedFlagImage(flagColor.stroke, flagColor.fill)
if (!cachedFlag?.complete) return
const flagSize = size * 0.8
const flagX = node.x + 0.8 + size * 0.5 - flagSize / 2
const flagY = node.y - 1.4 - size * 0.5 - flagSize / 2
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = 1
ctx.drawImage(cachedFlag, flagX, flagY, flagSize, flagSize)
ctx.globalAlpha = prevAlpha
}
// --- Shared node shape drawing (fill + stroke in one path) ---
const drawNodeShape = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
shape: NodeShape,
nodeColor: string,
isOutlined: boolean,
rc: RenderContext
) => {
drawNodePath(ctx, x, y, size, shape)
if (isOutlined) {
ctx.fillStyle = rc.themeBgFill
ctx.fill()
ctx.strokeStyle = nodeColor
ctx.lineWidth = Math.max(2, size * 0.02) / rc.globalScale
ctx.stroke()
} else {
ctx.fillStyle = nodeColor
ctx.fill()
// Stroke the same path — no need to rebuild it
ctx.strokeStyle = rc.themeSubtleBorder
ctx.lineWidth = 0.3
ctx.stroke()
}
}
// --- Icons/images rendering ---
const drawNodeIcon = (
ctx: CanvasRenderingContext2D,
node: GraphNode,
x: number,
y: number,
size: number,
shape: NodeShape,
isOutlined: boolean,
isHighlighted: boolean,
hasAnyHighlight: boolean,
theme: string
) => {
const iconColor = isOutlined ? (theme === 'dark' ? '#FFFFFF' : '#000000') : '#FFFFFF'
const visual = getNodeVisual(node, iconColor)
if (!visual) return
const iconAlpha = hasAnyHighlight && !isHighlighted ? 0.5 : 0.9
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = iconAlpha
if (visual.isExternal) {
const imgSize = size * 0.9
drawClippedImage(ctx, visual.image, x, y, imgSize, shape)
} else {
const iconSize = size * 1.2
ctx.drawImage(visual.image, x - iconSize / 2, y - iconSize / 2, iconSize, iconSize)
}
ctx.globalAlpha = prevAlpha
}
// --- Label rendering ---
const drawNodeLabel = (
ctx: CanvasRenderingContext2D,
node: GraphNode,
size: number,
isHighlighted: boolean,
forceSettings: any,
rc: RenderContext
) => {
const label = truncateText(node.nodeLabel || node.id, 58)
if (!label) return
const baseFontSize = Math.max(
CONSTANTS.MIN_FONT_SIZE,
(CONSTANTS.NODE_FONT_SIZE * (size / 2)) / rc.globalScale + 2
)
const nodeLabelSetting = forceSettings?.nodeLabelFontSize?.value ?? 50
const fontSize = baseFontSize * (nodeLabelSetting / 100)
ctx.font = `${fontSize}px Sans-Serif`
const textWidth = ctx.measureText(label).width
const paddingX = fontSize * 0.4
const paddingY = fontSize * 0.25
const bgWidth = textWidth + paddingX * 2
const bgHeight = fontSize + paddingY * 2
const borderRadius = fontSize * 0.3
const bgY = node.y + size / 2 + fontSize * 0.6
const bgX = node.x - bgWidth / 2
ctx.beginPath()
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius)
ctx.fillStyle = isHighlighted ? rc.themeLabelBgHighlighted : rc.themeLabelBg
ctx.fill()
ctx.strokeStyle = rc.themeLabelBorder
ctx.lineWidth = 0.1
ctx.stroke()
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = isHighlighted ? rc.themeTextColor : `${rc.themeTextColor}CC`
const metrics = ctx.measureText(label)
const textY = bgY + paddingY + metrics.actualBoundingBoxAscent
ctx.fillText(label, node.x, textY)
}
// --- Mock label (zoomed out) ---
const drawMockLabel = (
ctx: CanvasRenderingContext2D,
nodeX: number,
nodeY: number,
size: number,
rc: RenderContext
) => {
const mockLabelWidth = 15
const mockLabelHeight = 3
const mockLabelY = nodeY + size + mockLabelHeight * 0.5
const mockLabelX = nodeX - mockLabelWidth / 2
const borderRadius = mockLabelHeight * 0.3
ctx.beginPath()
ctx.roundRect(mockLabelX, mockLabelY, mockLabelWidth, mockLabelHeight, borderRadius)
ctx.fillStyle = rc.themeMockLabelFill
ctx.fill()
}
// --- Params ---
export interface NodeRenderParams {
node: GraphNode node: GraphNode
ctx: CanvasRenderingContext2D ctx: CanvasRenderingContext2D
globalScale: number globalScale: number
@@ -137,61 +299,255 @@ interface NodeRenderParams {
highlightNodes: Set<string> highlightNodes: Set<string>
highlightLinks: Set<string> highlightLinks: Set<string>
hoverNode: string | null hoverNode: string | null
rc: RenderContext
} }
// Helper to check if node is in viewport // --- Card layout constants ---
const isInViewport = (node: any, ctx: CanvasRenderingContext2D, margin: number = 80): boolean => {
const transform = ctx.getTransform()
const canvasWidth = ctx.canvas.width
const canvasHeight = ctx.canvas.height
// Transform node position to screen coordinates const CARD_BASE = {
const screenX = node.x * transform.a + transform.e iconSize: 7,
const screenY = node.y * transform.d + transform.f paddingX: 4,
paddingY: 3,
gap: 3,
typeFontSize: 3.5,
labelFontSize: 4.5,
borderRadius: 3,
accentWidth: 1.5
} as const
// Check if within viewport bounds (with margin for smoother culling) const getCardScale = (node: GraphNode, forceSettings: any) => {
return ( const size = calculateNodeSize(node, forceSettings, true, 1)
screenX >= -margin && return Math.max(0.5, size / 5)
screenX <= canvasWidth + margin && }
screenY >= -margin &&
screenY <= canvasHeight + margin const getCardDimensions = (node: GraphNode, ctx: CanvasRenderingContext2D, forceSettings: any) => {
const s = getCardScale(node, forceSettings)
const label = truncateText(node.nodeLabel || node.id, 40)
const typeText = node.nodeType || ''
const labelFontSize = CARD_BASE.labelFontSize * s
const typeFontSize = CARD_BASE.typeFontSize * s
const iconSize = CARD_BASE.iconSize * s
const paddingX = CARD_BASE.paddingX * s
const gap = CARD_BASE.gap * s
const paddingY = CARD_BASE.paddingY * s
ctx.font = `bold ${labelFontSize}px Sans-Serif`
const labelWidth = ctx.measureText(label).width
ctx.font = `${typeFontSize}px Sans-Serif`
const typeWidth = ctx.measureText(typeText).width
const textWidth = Math.max(labelWidth, typeWidth)
const cardWidth = paddingX + iconSize + gap + textWidth + paddingX
const cardHeight = paddingY + Math.max(iconSize, typeFontSize + 2 * s + labelFontSize) + paddingY
return { cardWidth, cardHeight, label, typeText, scale: s }
}
// --- Card flag drawing ---
const drawCardFlag = (
ctx: CanvasRenderingContext2D,
node: GraphNode,
cardX: number,
cardY: number,
cardWidth: number,
s: number
) => {
const flagColor = FLAG_COLORS[node.nodeFlag!]
if (!flagColor) return
const cachedFlag = getCachedFlagImage(flagColor.stroke, flagColor.fill)
if (!cachedFlag?.complete) return
const fSize = 6 * s
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = 1
ctx.drawImage(
cachedFlag,
cardX + cardWidth - fSize * 0.6,
cardY - fSize * 0.4,
fSize,
fSize
) )
ctx.globalAlpha = prevAlpha
} }
export const renderNode = ({ // --- Card-style node renderer ---
node,
ctx,
globalScale,
forceSettings,
showLabels,
showIcons,
isCurrent,
isSelected,
theme,
highlightNodes,
highlightLinks,
hoverNode
}: NodeRenderParams) => {
// Early exit: skip entire node if outside viewport
const inViewport = isInViewport(node, ctx)
if (!inViewport) return
const shouldRenderDetails = globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD const renderCardNode = (params: NodeRenderParams) => {
const {
node,
ctx,
forceSettings,
showIcons,
isCurrent,
isSelected,
theme,
highlightNodes,
hoverNode,
rc
} = params
const isHighlighted = highlightNodes.has(node.id) || isSelected(node.id) || isCurrent(node.id)
const isHovered = hoverNode === node.id || isCurrent(node.id)
const nodeColor = rc.hasAnyHighlight
? isHighlighted
? node.nodeColor
: getDimmedColor(rc, node.nodeColor!)
: node.nodeColor
const isOutlined = forceSettings.nodeOutlined?.value ?? false
const {
cardWidth,
cardHeight,
label,
typeText,
scale: s
} = getCardDimensions(node, ctx, forceSettings)
const borderRadius = CARD_BASE.borderRadius * s
const accentWidth = CARD_BASE.accentWidth * s
const iconSize = CARD_BASE.iconSize * s
const paddingX = CARD_BASE.paddingX * s
const paddingY = CARD_BASE.paddingY * s
const gap = CARD_BASE.gap * s
const typeFontSize = CARD_BASE.typeFontSize * s
const labelFontSize = CARD_BASE.labelFontSize * s
const cardX = node.x - cardWidth / 2
const cardY = node.y - cardHeight / 2
// Highlight ring
if (isHighlighted) {
const border = 2 / rc.globalScale
ctx.beginPath()
ctx.roundRect(
cardX - border,
cardY - border,
cardWidth + border * 2,
cardHeight + border * 2,
borderRadius + border
)
ctx.fillStyle = isHovered
? GRAPH_COLORS.NODE_HIGHLIGHT_HOVER
: GRAPH_COLORS.NODE_HIGHLIGHT_DEFAULT
ctx.fill()
}
// Card background
ctx.beginPath()
ctx.roundRect(cardX, cardY, cardWidth, cardHeight, borderRadius)
if (isOutlined) {
ctx.fillStyle = rc.themeBgFill
ctx.fill()
ctx.strokeStyle = nodeColor!
ctx.lineWidth = Math.max(0.5, 0.3 * s)
ctx.stroke()
} else {
ctx.fillStyle = nodeColor!
ctx.fill()
ctx.strokeStyle = rc.themeSubtleBorder
ctx.lineWidth = 0.3
ctx.stroke()
}
// Left color accent bar (outlined only)
if (isOutlined) {
ctx.save()
ctx.beginPath()
ctx.roundRect(cardX, cardY, cardWidth, cardHeight, borderRadius)
ctx.clip()
ctx.fillStyle = nodeColor!
ctx.fillRect(cardX, cardY, accentWidth, cardHeight)
ctx.restore()
}
const iconX = cardX + paddingX + (isOutlined ? accentWidth : 0)
const iconY = node.y - iconSize / 2
// Icon + text only when zoomed in enough
if (rc.shouldRenderDetails) {
if (showIcons) {
const iconColor = isOutlined ? (theme === 'dark' ? '#FFFFFF' : '#000000') : '#FFFFFF'
const visual = getNodeVisual(node, iconColor)
if (visual) {
const iconAlpha = rc.hasAnyHighlight && !isHighlighted ? 0.5 : 0.9
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = iconAlpha
ctx.drawImage(visual.image, iconX, iconY, iconSize, iconSize)
ctx.globalAlpha = prevAlpha
}
}
const textX = iconX + iconSize + gap
const textColor = rc.themeTextColor
if (typeText) {
ctx.font = `${typeFontSize}px Sans-Serif`
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
if (isOutlined) {
ctx.fillStyle = isHighlighted ? `${textColor}AA` : `${textColor}77`
} else {
ctx.fillStyle = isHighlighted ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.5)'
}
ctx.fillText(typeText, textX, cardY + paddingY)
}
if (label) {
ctx.font = `bold ${labelFontSize}px Sans-Serif`
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
if (isOutlined) {
ctx.fillStyle = isHighlighted ? textColor : `${textColor}CC`
} else {
ctx.fillStyle = isHighlighted ? '#FFFFFF' : 'rgba(255,255,255,0.85)'
}
const labelY = cardY + paddingY + (typeText ? typeFontSize + 2 * s : 0)
ctx.fillText(label, textX, labelY)
}
}
// Flag
if (node.nodeFlag) {
drawCardFlag(ctx, node, cardX, cardY, cardWidth, s)
}
}
// --- Dot-style node renderer ---
const renderDotNode = (params: NodeRenderParams) => {
const {
node,
ctx,
forceSettings,
showLabels,
showIcons,
isCurrent,
isSelected,
theme,
highlightNodes,
hoverNode,
rc
} = params
const size = calculateNodeSize( const size = calculateNodeSize(
node, node,
forceSettings, forceSettings,
shouldRenderDetails, rc.shouldRenderDetails,
CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
) )
const isHighlighted = highlightNodes.has(node.id) || isSelected(node.id) || isCurrent(node.id) const isHighlighted = highlightNodes.has(node.id) || isSelected(node.id) || isCurrent(node.id)
const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0
const isHovered = hoverNode === node.id || isCurrent(node.id) const isHovered = hoverNode === node.id || isCurrent(node.id)
const shape: NodeShape = node.nodeShape ?? 'circle' const shape: NodeShape = node.nodeShape ?? 'circle'
// Draw highlight ring // Highlight ring
if (isHighlighted) { if (isHighlighted) {
const borderWidth = 3 / globalScale const borderWidth = 3 / rc.globalScale
drawNodePath(ctx, node.x, node.y, size + borderWidth, shape) drawNodePath(ctx, node.x, node.y, size + borderWidth, shape)
ctx.fillStyle = isHovered ctx.fillStyle = isHovered
? GRAPH_COLORS.NODE_HIGHLIGHT_HOVER ? GRAPH_COLORS.NODE_HIGHLIGHT_HOVER
@@ -199,190 +555,164 @@ export const renderNode = ({
ctx.fill() ctx.fill()
} }
// Set node color const nodeColor = rc.hasAnyHighlight
const nodeColor = hasAnyHighlight
? isHighlighted ? isHighlighted
? node.nodeColor ? node.nodeColor!
: `${node.nodeColor}7D` : getDimmedColor(rc, node.nodeColor!)
: node.nodeColor : node.nodeColor!
const isOutlined = forceSettings.nodeOutlined?.value ?? false const isOutlined = forceSettings.nodeOutlined?.value ?? false
// Draw node shape (filled or outlined) // Draw shape (fill + stroke in one path)
drawNodePath(ctx, node.x, node.y, size, shape) drawNodeShape(ctx, node.x, node.y, size, shape, nodeColor, isOutlined, rc)
if (isOutlined) { // Flag
// Fill background: white in light mode, dark in dark mode if (node.nodeFlag) {
ctx.fillStyle = theme === 'light' ? '#FFFFFF' : '#1a1a1a' drawFlag(ctx, node, size)
ctx.fill()
// Draw colored outline
ctx.strokeStyle = nodeColor
ctx.lineWidth = Math.max(2, size * 0.15) / globalScale
ctx.stroke()
} else {
ctx.fillStyle = nodeColor
ctx.fill()
// Draw subtle border for filled nodes
drawNodePath(ctx, node.x, node.y, size, shape)
ctx.strokeStyle = theme === 'light' ? 'rgba(44, 44, 44, 0.19)' : 'rgba(222, 222, 222, 0.13)'
ctx.lineWidth = 0.3
ctx.stroke()
} }
// Only render details if zoomed in enough // Zoomed out: mock label and early return
if (!shouldRenderDetails) { if (!rc.shouldRenderDetails) {
// render flag drawMockLabel(ctx, node.x, node.y, size, rc)
if (node.nodeFlag) {
const flagColors: Record<string, { stroke: string; fill: string }> = {
red: { stroke: '#f87171', fill: '#fecaca' },
orange: { stroke: '#fb923c', fill: '#fed7aa' },
blue: { stroke: '#60a5fa', fill: '#bfdbfe' },
green: { stroke: '#4ade80', fill: '#bbf7d0' },
yellow: { stroke: '#facc15', fill: '#fef08a' }
}
const flagColor = flagColors[node.nodeFlag]
if (flagColor) {
const cachedFlag = getCachedFlagImage(flagColor.stroke, flagColor.fill)
if (cachedFlag && cachedFlag.complete) {
try {
const flagSize = size * 0.8
const flagX = node.x + 0.8 + size * 0.5 - flagSize / 2
const flagY = node.y - 1.4 - size * 0.5 - flagSize / 2
ctx.save()
ctx.globalAlpha = 1
ctx.drawImage(cachedFlag, flagX, flagY, flagSize, flagSize)
ctx.restore()
} catch (error) {
console.warn('[node-renderer] Failed to draw flag:', error)
}
}
}
}
// Draw a small rectangle to mock a label under the node
const mockLabelWidth = 15
const mockLabelHeight = 3
const mockLabelY = node.y + size + mockLabelHeight * 0.5
const mockLabelX = node.x - mockLabelWidth / 2
const borderRadius = mockLabelHeight * 0.3
ctx.beginPath()
ctx.roundRect(mockLabelX, mockLabelY, mockLabelWidth, mockLabelHeight, borderRadius)
ctx.fillStyle = theme === 'light' ? 'rgba(0, 0, 0, 0.15)' : 'rgba(255, 255, 255, 0.15)'
ctx.fill()
return return
} }
// Draw flag if present // Icons
if (node.nodeFlag) {
const flagColors: Record<string, { stroke: string; fill: string }> = {
red: { stroke: '#f87171', fill: '#fecaca' },
orange: { stroke: '#fb923c', fill: '#fed7aa' },
blue: { stroke: '#60a5fa', fill: '#bfdbfe' },
green: { stroke: '#4ade80', fill: '#bbf7d0' },
yellow: { stroke: '#facc15', fill: '#fef08a' }
}
const flagColor = flagColors[node.nodeFlag]
if (flagColor) {
const cachedFlag = getCachedFlagImage(flagColor.stroke, flagColor.fill)
if (cachedFlag && cachedFlag.complete) {
try {
const flagSize = size * 0.8
const flagX = node.x + 0.8 + size * 0.5 - flagSize / 2
const flagY = node.y - 1.4 - size * 0.5 - flagSize / 2
ctx.save()
ctx.globalAlpha = 1
ctx.drawImage(cachedFlag, flagX, flagY, flagSize, flagSize)
ctx.restore()
} catch (error) {
console.warn('[node-renderer] Failed to draw flag:', error)
}
}
}
}
// Render icons/images
if (showIcons) { if (showIcons) {
const iconColor = isOutlined ? (theme === 'dark' ? '#FFFFFF' : '#000000') : '#FFFFFF' drawNodeIcon(
const visual = getNodeVisual(node, iconColor) ctx, node, node.x, node.y, size, shape,
isOutlined, isHighlighted, rc.hasAnyHighlight, theme
if (visual) { )
try {
const iconAlpha = hasAnyHighlight && !isHighlighted ? 0.5 : 0.9
ctx.save()
ctx.globalAlpha = iconAlpha
if (visual.isExternal) {
// External image: clipped to shape, 90% of node size, object-cover
const imgSize = size * 0.9
drawClippedImage(ctx, visual.image, node.x, node.y, imgSize, shape)
} else {
// Icon: draw normally
const iconSize = size * 1.2
ctx.drawImage(
visual.image,
node.x - iconSize / 2,
node.y - iconSize / 2,
iconSize,
iconSize
)
}
ctx.restore()
} catch (error) {}
}
} }
// Render labels // Labels
if (showLabels) { if (showLabels) {
const anonymise = false // TODO drawNodeLabel(ctx, node, size, isHighlighted, forceSettings, rc)
const label = anonymise ? '**************' : truncateText(node.nodeLabel || node.id, 58) }
if (label) { }
const baseFontSize = Math.max(
CONSTANTS.MIN_FONT_SIZE, // --- Main dispatcher ---
(CONSTANTS.NODE_FONT_SIZE * (size / 2)) / globalScale + 2
) export const renderNode = (params: NodeRenderParams) => {
const nodeLabelSetting = forceSettings?.nodeLabelFontSize?.value ?? 50 if (!isInViewport(params.node.x, params.node.y, params.ctx)) return
const fontSize = baseFontSize * (nodeLabelSetting / 100)
ctx.font = `${fontSize}px Sans-Serif` const isDotStyle = params.forceSettings?.dotStyle?.value ?? true
if (isDotStyle) {
const textWidth = ctx.measureText(label).width renderDotNode(params)
const paddingX = fontSize * 0.4 } else {
const paddingY = fontSize * 0.25 renderCardNode(params)
const bgWidth = textWidth + paddingX * 2 }
const bgHeight = fontSize + paddingY * 2 }
const borderRadius = fontSize * 0.3
const bgY = node.y + size / 2 + fontSize * 0.6 // --- Shape edge distance (for link/arrow positioning) ---
// Draw background const rectEdgeDistance = (angle: number, halfW: number, halfH: number): number => {
const bgX = node.x - bgWidth / 2 const absC = Math.abs(Math.cos(angle))
ctx.beginPath() const absS = Math.abs(Math.sin(angle))
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius) if (absC < 1e-6) return halfH
if (absS < 1e-6) return halfW
if (theme === 'light') { return Math.min(halfW / absC, halfH / absS)
ctx.fillStyle = isHighlighted ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.75)' }
} else {
ctx.fillStyle = isHighlighted ? 'rgba(32, 32, 32, 0.95)' : 'rgba(32, 32, 32, 0.75)' const squareEdgeDistance = (angle: number, size: number): number => {
} return rectEdgeDistance(angle, size, size)
ctx.fill() }
ctx.strokeStyle = theme === 'light' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)' const hexEdgeDistance = (angle: number, size: number): number => {
ctx.lineWidth = 0.1 const apothem = (size * SQRT3) / 2
ctx.stroke() let a = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
const sectorCenter = Math.round(a / (Math.PI / 3)) * (Math.PI / 3)
// Draw text with consistent baseline across browsers const offset = a - sectorCenter
const color = theme === 'light' ? GRAPH_COLORS.TEXT_LIGHT : GRAPH_COLORS.TEXT_DARK return apothem / Math.cos(offset)
ctx.textAlign = 'center' }
ctx.textBaseline = 'alphabetic' // More consistent across browsers than 'middle'
ctx.fillStyle = isHighlighted ? color : `${color}CC` const triangleEdgeDistance = (angle: number, size: number): number => {
const h = size * SQRT3
const metrics = ctx.measureText(label) const vertices = [
const textY = bgY + paddingY + metrics.actualBoundingBoxAscent { x: 0, y: -h / 2 },
ctx.fillText(label, node.x, textY) { x: size, y: h / 2 },
} { x: -size, y: h / 2 }
]
const dx = Math.cos(angle)
const dy = Math.sin(angle)
let minDist = Infinity
for (let i = 0; i < 3; i++) {
const v1 = vertices[i]
const v2 = vertices[(i + 1) % 3]
const ex = v2.x - v1.x
const ey = v2.y - v1.y
const denom = dx * ey - dy * ex
if (Math.abs(denom) < 1e-10) continue
const t = (v1.x * ey - v1.y * ex) / denom
const s = (v1.x * dy - v1.y * dx) / denom
if (t > 0 && s >= 0 && s <= 1) {
minDist = Math.min(minDist, t)
}
}
return minDist === Infinity ? size : minDist
}
const shapeEdgeDistance = (angle: number, size: number, shape: NodeShape): number => {
switch (shape) {
case 'square':
return squareEdgeDistance(angle, size)
case 'hexagon':
return hexEdgeDistance(angle, size)
case 'triangle':
return triangleEdgeDistance(angle, size)
case 'circle':
default:
return size
}
}
export const getNodeEdgeDistance = (
node: any,
angle: number,
forceSettings: any,
ctx: CanvasRenderingContext2D,
shouldRenderDetails: boolean
): number => {
const isDotStyle = forceSettings?.dotStyle?.value ?? true
if (!isDotStyle) {
const { cardWidth, cardHeight } = getCardDimensions(node, ctx, forceSettings)
return rectEdgeDistance(angle, cardWidth / 2, cardHeight / 2)
}
const size = calculateNodeSize(
node,
forceSettings,
shouldRenderDetails,
CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
)
const shape: NodeShape = node.nodeShape ?? 'circle'
return shapeEdgeDistance(angle, size, shape)
}
// --- Pointer area paint (hitbox) ---
export const paintNodePointerArea = (
node: GraphNode,
color: string,
ctx: CanvasRenderingContext2D,
forceSettings: any
) => {
const isDotStyle = forceSettings?.dotStyle?.value ?? true
if (isDotStyle) {
const size = calculateNodeSize(node, forceSettings, true, CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER)
ctx.beginPath()
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI)
ctx.fillStyle = color
ctx.fill()
} else {
const { cardWidth, cardHeight } = getCardDimensions(node, ctx, forceSettings)
ctx.fillStyle = color
ctx.fillRect(node.x - cardWidth / 2, node.y - cardHeight / 2, cardWidth, cardHeight)
} }
} }

View File

@@ -0,0 +1,148 @@
import { CONSTANTS, GRAPH_COLORS } from './constants'
/**
* Pre-computed per-frame values to avoid redundant work in per-element render loops.
* Create once per frame via `createRenderContext()`, pass into every renderNode/renderLink call.
*
* NOTE: Canvas transform is NOT cached here. It must be read fresh from `ctx` in viewport
* checks because panning changes the transform without changing globalScale or any React
* dependency, which would cause stale culling (nodes hidden when they shouldn't be).
*/
export interface RenderContext {
// Pre-computed flags
hasAnyHighlight: boolean
shouldRenderDetails: boolean
globalScale: number
// Selected edges as Set for O(1) lookup
selectedEdgeIds: Set<string>
// Theme-resolved colors
themeBgFill: string
themeSubtleBorder: string
themeMockLabelFill: string
themeLabelBg: string
themeLabelBgHighlighted: string
themeLabelBorder: string
themeTextColor: string
themeEdgeLabelBg: string
// Dimmed color cache (nodeColor -> nodeColor + "7D")
dimmedColorCache: Map<string, string>
}
export const createRenderContext = (
globalScale: number,
highlightNodes: Set<string>,
highlightLinks: Set<string>,
selectedEdges: { id: string }[],
theme: string
): RenderContext => {
const isLight = theme === 'light'
return {
hasAnyHighlight: highlightNodes.size > 0 || highlightLinks.size > 0,
shouldRenderDetails: globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD,
globalScale,
selectedEdgeIds: new Set(selectedEdges.map((e) => e.id)),
themeBgFill: isLight ? '#FFFFFF' : '#1a1a1a',
themeSubtleBorder: isLight
? 'rgba(44, 44, 44, 0.19)'
: 'rgba(222, 222, 222, 0.13)',
themeMockLabelFill: isLight
? 'rgba(0, 0, 0, 0.15)'
: 'rgba(255, 255, 255, 0.15)',
themeLabelBg: isLight
? 'rgba(255, 255, 255, 0.75)'
: 'rgba(32, 32, 32, 0.75)',
themeLabelBgHighlighted: isLight
? 'rgba(255, 255, 255, 0.95)'
: 'rgba(32, 32, 32, 0.95)',
themeLabelBorder: isLight
? 'rgba(0, 0, 0, 0.1)'
: 'rgba(255, 255, 255, 0.1)',
themeTextColor: isLight ? GRAPH_COLORS.TEXT_LIGHT : GRAPH_COLORS.TEXT_DARK,
themeEdgeLabelBg: isLight
? 'rgba(255, 255, 255, 0.95)'
: 'rgba(32, 32, 32, 0.95)',
dimmedColorCache: new Map()
}
}
/** Get dimmed color with caching to avoid string allocation per element */
export const getDimmedColor = (rc: RenderContext, color: string): string => {
let dimmed = rc.dimmedColorCache.get(color)
if (!dimmed) {
dimmed = `${color}7D`
rc.dimmedColorCache.set(color, dimmed)
}
return dimmed
}
/** Viewport check — reads transform fresh from ctx to stay correct during pan */
export const isInViewport = (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
margin: number = 80
): boolean => {
const transform = ctx.getTransform()
const screenX = x * transform.a + transform.e
const screenY = y * transform.d + transform.f
return (
screenX >= -margin &&
screenX <= ctx.canvas.width + margin &&
screenY >= -margin &&
screenY <= ctx.canvas.height + margin
)
}
/** Edge viewport check — reads transform fresh from ctx to stay correct during pan */
export const isEdgeInViewport = (
startX: number,
startY: number,
endX: number,
endY: number,
ctx: CanvasRenderingContext2D,
margin: number = 80
): boolean => {
const transform = ctx.getTransform()
const canvasWidth = ctx.canvas.width
const canvasHeight = ctx.canvas.height
const screenStartX = startX * transform.a + transform.e
const screenStartY = startY * transform.d + transform.f
if (
screenStartX >= -margin &&
screenStartX <= canvasWidth + margin &&
screenStartY >= -margin &&
screenStartY <= canvasHeight + margin
) return true
const screenEndX = endX * transform.a + transform.e
const screenEndY = endY * transform.d + transform.f
if (
screenEndX >= -margin &&
screenEndX <= canvasWidth + margin &&
screenEndY >= -margin &&
screenEndY <= canvasHeight + margin
) return true
// AABB intersection (edge might cross viewport even if both endpoints are outside)
const minX = Math.min(screenStartX, screenEndX)
const maxX = Math.max(screenStartX, screenEndX)
const minY = Math.min(screenStartY, screenEndY)
const maxY = Math.max(screenStartY, screenEndY)
return !(
maxX < -margin ||
minX > canvasWidth + margin ||
maxY < -margin ||
minY > canvasHeight + margin
)
}

View File

@@ -43,6 +43,12 @@ const DEFAULT_SETTINGS = {
value: false, value: false,
description: 'Node style, filled by default.' description: 'Node style, filled by default.'
}, },
dotStyle: {
name: 'Node style (dot/card)',
type: 'boolean',
value: true,
description: 'Node style, dot or card.'
},
nodeSize: { nodeSize: {
name: 'Node Size', name: 'Node Size',
type: 'number', type: 'number',