Files
resolutionflow/frontend/src/components/network/nodes/DeviceNode.tsx
chihlasm 89ca2a0fa5 fix(network): proportional node resize with locked aspect ratio
Nodes grew into rectangles because NodeResizer had no aspect ratio
constraint, minWidth != minHeight, and icon/text only scaled from width.

- DeviceNode: add keepAspectRatio + equal minWidth/minHeight (80×80),
  maxWidth/maxHeight (280×280), scale icon and label/IP font sizes from
  Math.min(width, height) so all content grows uniformly
- DiagramEditor: set explicit 120×120 style on dropped device nodes so
  React Flow has a definite starting size for aspect ratio calculation
- DiagramEditor: persist device node style (width/height) in
  serializeNodes and restore it on load so size survives save/reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 06:07:24 +00:00

107 lines
4.5 KiB
TypeScript

import { memo } from 'react'
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
import { BaseHandle } from '../ui/base-handle'
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
import { getDeviceRenderConfig } from './deviceRegistry'
import type { DeviceProperties } from '@/types'
export interface DeviceNodeData {
label: string
deviceType: string
category?: string
properties: DeviceProperties
[key: string]: unknown
}
function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null
return (
<div className="flex gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-mono text-primary">{value}</span>
</div>
)
}
const NODE_DEFAULT = 120 // default square side in px
const NODE_MIN = 80 // minimum square side in px
const NODE_MAX = 280 // maximum square side in px
function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip
const props = nodeData.properties || {}
// Use the shorter dimension so content never overflows a non-square node
const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT)
const scale = size / NODE_DEFAULT
// Icon: 28px at default, clamped to [14, 72]
const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28)))
// Label font: 11px at default, clamped to [9, 20]
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
// IP font: 9px at default, clamped to [8, 16]
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
return (
<>
<NodeResizer
isVisible={selected}
minWidth={NODE_MIN}
minHeight={NODE_MIN}
maxWidth={NODE_MAX}
maxHeight={NODE_MAX}
keepAspectRatio
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
/>
<NodeStatusIndicator status={status}>
<NodeTooltip>
<NodeTooltipTrigger>
<BaseNode className="w-full h-full group flex flex-col items-center justify-center">
<BaseNodeHeader className="flex-col gap-1 items-center py-2 px-2">
<Icon size={iconPx} style={{ color }} />
<BaseNodeHeaderTitle className="text-center leading-tight" style={{ fontSize: labelPx }}>
{nodeData.label}
</BaseNodeHeaderTitle>
</BaseNodeHeader>
{ip && (
<BaseNodeContent className="items-center pt-0 pb-1">
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
</BaseNodeContent>
)}
<BaseHandle type="target" position={Position.Top} />
<BaseHandle type="source" position={Position.Bottom} />
<BaseHandle type="target" position={Position.Left} id="left" />
<BaseHandle type="source" position={Position.Right} id="right" />
</BaseNode>
</NodeTooltipTrigger>
{hasTooltipContent && (
<NodeTooltipContent position={Position.Top}>
<div className="flex flex-col gap-1 min-w-[140px]">
<TooltipRow label="Host" value={props.hostname} />
<TooltipRow label="IP" value={props.ip} />
{(props.vendor || props.model) && (
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
)}
<TooltipRow label="Role" value={props.role} />
{props.notes && (
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
)}
</div>
</NodeTooltipContent>
)}
</NodeTooltip>
</NodeStatusIndicator>
</>
)
}
export const DeviceNode = memo(DeviceNodeComponent)