From 611bc1a56354660493d86d2f1a4bfcb6c2504d2f Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Sun, 8 Mar 2026 20:01:55 +0100 Subject: [PATCH] feat(app): improve node and edge rendering --- flowsint-app/.DS_Store | Bin 8196 -> 0 bytes .../sketches/graph/edge/link-renderer.ts | 285 +++---- .../src/components/sketches/graph/index.tsx | 48 +- .../sketches/graph/node/node-renderer.ts | 788 +++++++++++++----- .../sketches/graph/utils/render-context.ts | 148 ++++ .../src/stores/graph-settings-store.ts | 6 + 6 files changed, 863 insertions(+), 412 deletions(-) delete mode 100644 flowsint-app/.DS_Store create mode 100644 flowsint-app/src/components/sketches/graph/utils/render-context.ts diff --git a/flowsint-app/.DS_Store b/flowsint-app/.DS_Store deleted file mode 100644 index 745ae0cfe42acc904dbfdbd63caae07cab0ec244..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&u<$=6n^8Rjh&yHG=WfrWYrg>Rw1PYDMF~mO;jR;62(!P&>v>K9y?3cGuH0f zX&OcH8BTDAJ6BGXdf~{0D>uZS09Q`%&CI&pwUaAC?Tj?@-tN3_-p*&eH+d5xVwG;= zCebnx<joWlfW{OPSx#i%NDalBrg~rr zlqDqwGjPlol{sWNk$oc#oS1tAqc4nOh7z~ zVb_K*v*%%G1s*x|hOplaVJG21Xv-%+9O6!*O+gluQ%NMfmq{R68mBOd6+z7*OgMy@ zhK)FxbVs0?|i-$k7?Xx;JQ?Y51+iO56$0&w)Nwv zll+d)>Yc!k;?>pPSZ-|m(&dT#M1Cs&sCp=mtFaq*qlz0oP*1y32K9mN*7tmVTyqxh zN)fxh;GqWi4Gxos_kGcj#}(NXjYwmwW+Gq67i!M(>FKSTYd4CwKUh1vQ9NCHe--+z z56{jD`K8i(AMMnRg0_%fW3Ol|N%Ayfo?ndLK+Ejk_j<{m*sp^<{oy!+T@@y$re|j7 zocV<-^NaIKOG|IOx%}4Ex3AWmD|Od9YK7`+@5(@im7vQXv_ij8?YN#VTKh475?B4F zyyh$nneOn$XP|)_JfcDl(2`d*An@Fke2Wm&N0D z$kmdiJAWA}OXbf7%DYB!sALq>1B(MKk*xGH?`wTmNWlxG^0t{N;LyHKpU~&Dhp>K1 z-_rN=BR!{I=y&>){$>-*Vb|DocAI_7?yyhUCi{YU?2!3P1rb_q@I$|2o(CgS*N2%O zx>pTFAVmxB6{bKtv;oiS@V$Z1X;Wh;6jVA$he82K2SJGd?LzZL15_u2G-3?au*|NT zQ(vC8n$ee?Nvc3dwk`~0Bu+M)N&vIegMF++Ka0opY1S(t31qMZIzjRXk$P5;2y|M_ zB2+5KAn@6?Sq9P!jG2dJRsU~XeE*+@Slu#U8Fd)jN`j_ac usS~(wq&~ngC=>Nwm*bEx{$U8cn9P+E**6k1X#f31fIa^;@pgEv8Tc2vB;6 { - const transform = ctx.getTransform() - const canvasWidth = ctx.canvas.width - const canvasHeight = ctx.canvas.height - - // Transform position to screen coordinates - const screenX = x * transform.a + transform.e - const screenY = y * transform.d + transform.f - - // Check if within viewport bounds (with margin for smoother culling) - 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 - ) +// Module-level bezier tangent to avoid closure allocation per link +const bezierTangentAt1 = ( + startX: number, + startY: number, + ctrlX: number, + ctrlY: number, + endX: number, + endY: number, + isCurved: boolean +) => { + if (!isCurved) { + return { x: endX - startX, y: endY - startY } + } + return { + x: 2 * (endX - ctrlX), + y: 2 * (endY - ctrlY) + } } export const renderLink = ({ @@ -82,30 +40,26 @@ export const renderLink = ({ ctx, globalScale, forceSettings, - theme, highlightLinks, - highlightNodes, - selectedEdges, currentEdge, - autoColorLinksByNodeType + autoColorLinksByNodeType, + rc }: LinkRenderParams) => { if (globalScale < CONSTANTS.ZOOM_EDGE_DETAIL_THRESHOLD) return const { source: start, target: end } = link if (typeof start !== 'object' || typeof end !== 'object') return - // Early exit: skip edge if outside viewport - if (!isEdgeInViewport(link, ctx)) return + // Viewport culling using pre-computed transform (no DOMMatrix allocation) + if (!isEdgeInViewport(start.x, start.y, end.x, end.y, ctx)) return const linkKey = `${start.id}-${end.id}` 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 hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0 let linkWidthBase = forceSettings?.linkWidth?.value ?? 2 - const shouldRenderDetails = globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD - const linkWidth = shouldRenderDetails + const linkWidth = rc.shouldRenderDetails ? linkWidthBase : linkWidthBase * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER @@ -114,76 +68,86 @@ export const renderLink = ({ : GRAPH_COLORS.LINK_DEFAULT let strokeStyle: string - let lineWidth: number let fillStyle: string + let lineWidth: number if (isCurrent) { strokeStyle = 'rgba(59, 130, 246, 0.95)' - fillStyle = 'rgba(59, 130, 246, 0.95)' + fillStyle = strokeStyle lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 2.3) } else if (isSelected) { strokeStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_HIGHLIGHTED - fillStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_HIGHLIGHTED + fillStyle = strokeStyle lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 2.5) } else if (isHighlighted) { strokeStyle = GRAPH_COLORS.LINK_HIGHLIGHTED - fillStyle = GRAPH_COLORS.LINK_HIGHLIGHTED + fillStyle = strokeStyle lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 3) - } else if (hasAnyHighlight) { + } else if (rc.hasAnyHighlight) { strokeStyle = GRAPH_COLORS.LINK_DIMMED - fillStyle = GRAPH_COLORS.LINK_DIMMED + fillStyle = strokeStyle lineWidth = CONSTANTS.LINK_WIDTH * (linkWidth / 5) } else { strokeStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_DEFAULT - fillStyle = autoColorLinksByNodeType ? targetNodeColor : GRAPH_COLORS.LINK_DEFAULT + fillStyle = strokeStyle 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 arrowLength = shouldRenderDetails + const arrowLength = rc.shouldRenderDetails ? arrowLengthSetting : arrowLengthSetting * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER - // Draw connection line + // Geometry const curvature: number = link.curvature || 0 const dx = end.x - start.x const dy = end.y - start.y const distance = Math.sqrt(dx * dx + dy * dy) || 1 - // Shorten the line to stop at node edges - const startRatio = startRadius / distance - 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 origMidX = (start.x + end.x) * 0.5 + const origMidY = (start.y + end.y) * 0.5 const normX = -dy / distance const normY = dx / distance const offset = curvature * distance - const ctrlX = midX + normX * offset - const ctrlY = midY + normY * offset + const ctrlX = origMidX + normX * 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.moveTo(adjustedStartX, adjustedStartY) - if (curvature !== 0) { + if (isCurved) { ctx.quadraticCurveTo(ctrlX, ctrlY, adjustedEndX, adjustedEndY) } else { ctx.lineTo(adjustedEndX, adjustedEndY) @@ -192,59 +156,23 @@ export const renderLink = ({ ctx.lineWidth = lineWidth ctx.stroke() - // Draw directional arrow + // Arrow if (arrowLength && arrowLength > 0) { - const arrowRelPos = forceSettings?.linkDirectionalArrowRelPos?.value || 1 - - const bezierPoint = (t: number) => { - if (curvature === 0) { - return { x: start.x + dx * t, y: start.y + dy * t } - } - const oneMinusT = 1 - t - 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) + const endTan = bezierTangentAt1( + adjustedStartX, adjustedStartY, + ctrlX, ctrlY, + adjustedEndX, adjustedEndY, + isCurved + ) + const angle = Math.atan2(endTan.y, endTan.x) ctx.save() - ctx.translate(arrowX, arrowY) + ctx.translate(adjustedEndX, adjustedEndY) ctx.rotate(angle) ctx.beginPath() - ctx.moveTo(0, 0) - ctx.lineTo(-arrowLength, -arrowLength * 0.5) - ctx.lineTo(-arrowLength, arrowLength * 0.5) + ctx.moveTo(arrowLength, 0) + ctx.lineTo(0, -arrowLength * 0.5) + ctx.lineTo(0, arrowLength * 0.5) ctx.closePath() ctx.fillStyle = fillStyle ctx.fill() @@ -253,22 +181,24 @@ export const renderLink = ({ 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) { let textAngle: number - if ((link.curvature || 0) !== 0) { + if (isCurved) { const t = 0.5 - const oneMinusT = 1 - t - tempPos.x = oneMinusT * oneMinusT * start.x + 2 * oneMinusT * t * ctrlX + t * t * end.x - tempPos.y = oneMinusT * oneMinusT * start.y + 2 * oneMinusT * t * ctrlY + t * t * end.y - const tx = 2 * oneMinusT * (ctrlX - start.x) + 2 * t * (end.x - ctrlX) - const ty = 2 * oneMinusT * (ctrlY - start.y) + 2 * t * (end.y - ctrlY) + const oneMinusT = 0.5 + tempPos.x = + oneMinusT * oneMinusT * adjustedStartX + 2 * oneMinusT * t * ctrlX + t * t * adjustedEndX + tempPos.y = + 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) } else { - tempPos.x = (start.x + end.x) * 0.5 - tempPos.y = (start.y + end.y) * 0.5 - const sdx = end.x - start.x - const sdy = end.y - start.y + tempPos.x = (adjustedStartX + adjustedEndX) * 0.5 + tempPos.y = (adjustedStartY + adjustedEndY) * 0.5 + const sdx = adjustedEndX - adjustedStartX + const sdy = adjustedEndY - adjustedStartY textAngle = Math.atan2(sdy, sdx) } @@ -279,7 +209,10 @@ export const renderLink = ({ const linkLabelSetting = forceSettings?.linkLabelFontSize?.value ?? 50 const linkFontSize = CONSTANTS.LABEL_FONT_SIZE * (linkLabelSetting / 100) 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 tempDimensions[0] = textWidth + padding tempDimensions[1] = linkFontSize + padding @@ -294,14 +227,10 @@ export const renderLink = ({ ctx.beginPath() ctx.roundRect(-halfWidth, -halfHeight, tempDimensions[0], tempDimensions[1], borderRadius) - if (theme === 'light') { - ctx.fillStyle = 'rgba(255, 255, 255, 0.95)' - } else { - ctx.fillStyle = 'rgba(32, 32, 32, 0.95)' - } + ctx.fillStyle = rc.themeEdgeLabelBg 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.stroke() @@ -309,12 +238,10 @@ export const renderLink = ({ ? GRAPH_COLORS.LINK_LABEL_HIGHLIGHTED : GRAPH_COLORS.LINK_LABEL_DEFAULT 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 = - labelMetrics.actualBoundingBoxAscent * 0.5 - labelMetrics.actualBoundingBoxDescent * 0.5 + metrics.actualBoundingBoxAscent * 0.5 - metrics.actualBoundingBoxDescent * 0.5 ctx.fillText(link.label, 0, labelTextY) ctx.restore() diff --git a/flowsint-app/src/components/sketches/graph/index.tsx b/flowsint-app/src/components/sketches/graph/index.tsx index 4ccf5ee..80d57dc 100644 --- a/flowsint-app/src/components/sketches/graph/index.tsx +++ b/flowsint-app/src/components/sketches/graph/index.tsx @@ -18,6 +18,7 @@ import { import { renderNode } from './node/node-renderer' import { useKeyboardEvents } from './hooks/use-keyboard-events' import { renderLink } from './edge/link-renderer' +import { createRenderContext, RenderContext } from './utils/render-context' import { useHighlightState } from './hooks/use-highlight-state' import { useComputedHighlights } from './hooks/use-computed-highlights' import { useTooltip } from './hooks/use-tooltip' @@ -368,8 +369,42 @@ const GraphViewer: React.FC = ({ setImportModalOpen(true) }, [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( (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const rc = getOrCreateRC(globalScale) renderNode({ node, ctx, @@ -382,7 +417,8 @@ const GraphViewer: React.FC = ({ theme, highlightNodes, highlightLinks, - hoverNode + hoverNode, + rc }) }, [ @@ -394,12 +430,14 @@ const GraphViewer: React.FC = ({ theme, highlightNodes, highlightLinks, - hoverNode + hoverNode, + getOrCreateRC ] ) const renderLinkCallback = useCallback( (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const rc = getOrCreateRC(globalScale) renderLink({ link, ctx, @@ -410,7 +448,8 @@ const GraphViewer: React.FC = ({ highlightNodes, selectedEdges, currentEdge, - autoColorLinksByNodeType + autoColorLinksByNodeType, + rc }) }, [ @@ -420,7 +459,8 @@ const GraphViewer: React.FC = ({ highlightNodes, selectedEdges, currentEdge, - autoColorLinksByNodeType + autoColorLinksByNodeType, + getOrCreateRC ] ) diff --git a/flowsint-app/src/components/sketches/graph/node/node-renderer.ts b/flowsint-app/src/components/sketches/graph/node/node-renderer.ts index fc1978e..62a877c 100644 --- a/flowsint-app/src/components/sketches/graph/node/node-renderer.ts +++ b/flowsint-app/src/components/sketches/graph/node/node-renderer.ts @@ -7,37 +7,47 @@ import { getCachedExternalImage } from '../utils/image-cache' import { truncateText, calculateNodeSize } from '../utils/utils' +import { RenderContext, isInViewport, getDimmedColor } from '../utils/render-context' type NodeVisual = { image: HTMLImageElement isExternal: boolean } | null -// Shape path helpers - each creates a path centered at (x, y) with given size +const FLAG_COLORS: Record = { + 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) => { ctx.arc(x, y, size, 0, 2 * Math.PI) } const drawSquarePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { - const half = size - ctx.rect(x - half, y - half, half * 2, half * 2) + ctx.rect(x - size, y - size, size * 2, size * 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) => { - // Flat-top hexagon - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i - Math.PI / 6 - 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.moveTo(x + size * HEX_COS[0], y + size * HEX_SIN[0]) + for (let i = 1; i < 6; i++) { + ctx.lineTo(x + size * HEX_COS[i], y + size * HEX_SIN[i]) } ctx.closePath() } +const SQRT3 = Math.sqrt(3) + const drawTrianglePath = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { - // Equilateral triangle pointing up - const height = size * Math.sqrt(3) + const height = size * SQRT3 const topY = y - height / 2 const bottomY = y + height / 2 ctx.moveTo(x, topY) @@ -46,7 +56,6 @@ const drawTrianglePath = (ctx: CanvasRenderingContext2D, x: number, y: number, s ctx.closePath() } -// Unified shape path drawer const drawNodePath = ( ctx: CanvasRenderingContext2D, x: number, @@ -72,7 +81,8 @@ const drawNodePath = ( } } -// Draws an image with object-cover behavior inside a shape clip +// --- Clipped image drawing --- + const drawClippedImage = ( ctx: CanvasRenderingContext2D, img: HTMLImageElement, @@ -84,14 +94,11 @@ const drawClippedImage = ( const diameter = size * 2 const imgAspect = img.naturalWidth / img.naturalHeight - // Calculate cover dimensions (fill the shape, crop excess) let drawWidth: number, drawHeight: number if (imgAspect > 1) { - // Landscape: height fills, width overflows drawHeight = diameter drawWidth = diameter * imgAspect } else { - // Portrait or square: width fills, height overflows drawWidth = diameter drawHeight = diameter / imgAspect } @@ -101,21 +108,19 @@ const drawClippedImage = ( 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 => { - // Priority 1: External image (nodeImage URL) if (node.nodeImage) { const img = getCachedExternalImage(node.nodeImage) if (img?.complete) return { image: img, isExternal: true } } - // Priority 2: Custom icon by name (nodeIcon) if (node.nodeIcon) { const img = getCachedIconByName(node.nodeIcon, iconColor) if (img?.complete) return { image: img, isExternal: false } } - // Priority 3: Type-based icon (nodeType) if (node.nodeType) { const img = getCachedImage(node.nodeType, iconColor) if (img?.complete) return { image: img, isExternal: false } @@ -124,7 +129,164 @@ const getNodeVisual = (node: GraphNode, iconColor: string): NodeVisual => { 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 ctx: CanvasRenderingContext2D globalScale: number @@ -137,61 +299,255 @@ interface NodeRenderParams { highlightNodes: Set highlightLinks: Set hoverNode: string | null + rc: RenderContext } -// Helper to check if node is in viewport -const isInViewport = (node: any, ctx: CanvasRenderingContext2D, margin: number = 80): boolean => { - const transform = ctx.getTransform() - const canvasWidth = ctx.canvas.width - const canvasHeight = ctx.canvas.height +// --- Card layout constants --- - // Transform node position to screen coordinates - const screenX = node.x * transform.a + transform.e - const screenY = node.y * transform.d + transform.f +const CARD_BASE = { + iconSize: 7, + 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) - return ( - screenX >= -margin && - screenX <= canvasWidth + margin && - screenY >= -margin && - screenY <= canvasHeight + margin +const getCardScale = (node: GraphNode, forceSettings: any) => { + const size = calculateNodeSize(node, forceSettings, true, 1) + return Math.max(0.5, size / 5) +} + +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 = ({ - 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 +// --- Card-style node renderer --- - 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( node, forceSettings, - shouldRenderDetails, + rc.shouldRenderDetails, CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER ) 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 shape: NodeShape = node.nodeShape ?? 'circle' - // Draw highlight ring + // Highlight ring if (isHighlighted) { - const borderWidth = 3 / globalScale + const borderWidth = 3 / rc.globalScale drawNodePath(ctx, node.x, node.y, size + borderWidth, shape) ctx.fillStyle = isHovered ? GRAPH_COLORS.NODE_HIGHLIGHT_HOVER @@ -199,190 +555,164 @@ export const renderNode = ({ ctx.fill() } - // Set node color - const nodeColor = hasAnyHighlight + const nodeColor = rc.hasAnyHighlight ? isHighlighted - ? node.nodeColor - : `${node.nodeColor}7D` - : node.nodeColor + ? node.nodeColor! + : getDimmedColor(rc, node.nodeColor!) + : node.nodeColor! const isOutlined = forceSettings.nodeOutlined?.value ?? false - // Draw node shape (filled or outlined) - drawNodePath(ctx, node.x, node.y, size, shape) + // Draw shape (fill + stroke in one path) + drawNodeShape(ctx, node.x, node.y, size, shape, nodeColor, isOutlined, rc) - if (isOutlined) { - // Fill background: white in light mode, dark in dark mode - ctx.fillStyle = theme === 'light' ? '#FFFFFF' : '#1a1a1a' - 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() + // Flag + if (node.nodeFlag) { + drawFlag(ctx, node, size) } - // Only render details if zoomed in enough - if (!shouldRenderDetails) { - // render flag - if (node.nodeFlag) { - const flagColors: Record = { - 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() + // Zoomed out: mock label and early return + if (!rc.shouldRenderDetails) { + drawMockLabel(ctx, node.x, node.y, size, rc) return } - // Draw flag if present - if (node.nodeFlag) { - const flagColors: Record = { - 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 + // Icons if (showIcons) { - const iconColor = isOutlined ? (theme === 'dark' ? '#FFFFFF' : '#000000') : '#FFFFFF' - const visual = getNodeVisual(node, iconColor) - - 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) {} - } + drawNodeIcon( + ctx, node, node.x, node.y, size, shape, + isOutlined, isHighlighted, rc.hasAnyHighlight, theme + ) } - // Render labels + // Labels if (showLabels) { - const anonymise = false // TODO - const label = anonymise ? '**************' : truncateText(node.nodeLabel || node.id, 58) - if (label) { - const baseFontSize = Math.max( - CONSTANTS.MIN_FONT_SIZE, - (CONSTANTS.NODE_FONT_SIZE * (size / 2)) / 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 - - // Draw background - const bgX = node.x - bgWidth / 2 - ctx.beginPath() - ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius) - - if (theme === 'light') { - 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)' - } - ctx.fill() - - ctx.strokeStyle = theme === 'light' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)' - ctx.lineWidth = 0.1 - ctx.stroke() - - // Draw text with consistent baseline across browsers - const color = theme === 'light' ? GRAPH_COLORS.TEXT_LIGHT : GRAPH_COLORS.TEXT_DARK - ctx.textAlign = 'center' - ctx.textBaseline = 'alphabetic' // More consistent across browsers than 'middle' - ctx.fillStyle = isHighlighted ? color : `${color}CC` - - const metrics = ctx.measureText(label) - const textY = bgY + paddingY + metrics.actualBoundingBoxAscent - ctx.fillText(label, node.x, textY) - } + drawNodeLabel(ctx, node, size, isHighlighted, forceSettings, rc) + } +} + +// --- Main dispatcher --- + +export const renderNode = (params: NodeRenderParams) => { + if (!isInViewport(params.node.x, params.node.y, params.ctx)) return + + const isDotStyle = params.forceSettings?.dotStyle?.value ?? true + if (isDotStyle) { + renderDotNode(params) + } else { + renderCardNode(params) + } +} + +// --- Shape edge distance (for link/arrow positioning) --- + +const rectEdgeDistance = (angle: number, halfW: number, halfH: number): number => { + const absC = Math.abs(Math.cos(angle)) + const absS = Math.abs(Math.sin(angle)) + if (absC < 1e-6) return halfH + if (absS < 1e-6) return halfW + return Math.min(halfW / absC, halfH / absS) +} + +const squareEdgeDistance = (angle: number, size: number): number => { + return rectEdgeDistance(angle, size, size) +} + +const hexEdgeDistance = (angle: number, size: number): number => { + const apothem = (size * SQRT3) / 2 + let a = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) + const sectorCenter = Math.round(a / (Math.PI / 3)) * (Math.PI / 3) + const offset = a - sectorCenter + return apothem / Math.cos(offset) +} + +const triangleEdgeDistance = (angle: number, size: number): number => { + const h = size * SQRT3 + const vertices = [ + { x: 0, y: -h / 2 }, + { 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) } } diff --git a/flowsint-app/src/components/sketches/graph/utils/render-context.ts b/flowsint-app/src/components/sketches/graph/utils/render-context.ts new file mode 100644 index 0000000..1ec507b --- /dev/null +++ b/flowsint-app/src/components/sketches/graph/utils/render-context.ts @@ -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 + + // 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 +} + +export const createRenderContext = ( + globalScale: number, + highlightNodes: Set, + highlightLinks: Set, + 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 + ) +} diff --git a/flowsint-app/src/stores/graph-settings-store.ts b/flowsint-app/src/stores/graph-settings-store.ts index b30082b..02e7871 100644 --- a/flowsint-app/src/stores/graph-settings-store.ts +++ b/flowsint-app/src/stores/graph-settings-store.ts @@ -43,6 +43,12 @@ const DEFAULT_SETTINGS = { value: false, description: 'Node style, filled by default.' }, + dotStyle: { + name: 'Node style (dot/card)', + type: 'boolean', + value: true, + description: 'Node style, dot or card.' + }, nodeSize: { name: 'Node Size', type: 'number',