mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-08 23:04:17 -05:00
feat(app): improve node and edge rendering
This commit is contained in:
BIN
flowsint-app/.DS_Store
vendored
BIN
flowsint-app/.DS_Store
vendored
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user