mirror of
https://github.com/reconurge/flowsint.git
synced 2026-04-29 18:59:14 -05:00
feat(app): improve node and edge rendering
This commit is contained in:
@@ -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<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) => {
|
||||
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<string>
|
||||
highlightLinks: Set<string>
|
||||
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<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()
|
||||
// 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<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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user