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>
This commit is contained in:
@@ -25,15 +25,27 @@ function TooltipRow({ label, value }: { label: string; value: string | null | un
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceNodeComponent({ data, selected, width }: NodeProps) {
|
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 nodeData = data as unknown as DeviceNodeData
|
||||||
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||||
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
||||||
const ip = nodeData.properties?.ip
|
const ip = nodeData.properties?.ip
|
||||||
const props = nodeData.properties || {}
|
const props = nodeData.properties || {}
|
||||||
|
|
||||||
// Scale icon proportionally: 28px at default 120px wide, clamped 16–60px
|
// Use the shorter dimension so content never overflows a non-square node
|
||||||
const iconPx = Math.round(Math.max(16, Math.min(60, ((width ?? 120) / 120) * 28)))
|
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
|
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
||||||
|
|
||||||
@@ -41,24 +53,27 @@ function DeviceNodeComponent({ data, selected, width }: NodeProps) {
|
|||||||
<>
|
<>
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
minWidth={80}
|
minWidth={NODE_MIN}
|
||||||
minHeight={70}
|
minHeight={NODE_MIN}
|
||||||
|
maxWidth={NODE_MAX}
|
||||||
|
maxHeight={NODE_MAX}
|
||||||
|
keepAspectRatio
|
||||||
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
||||||
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
||||||
/>
|
/>
|
||||||
<NodeStatusIndicator status={status}>
|
<NodeStatusIndicator status={status}>
|
||||||
<NodeTooltip>
|
<NodeTooltip>
|
||||||
<NodeTooltipTrigger>
|
<NodeTooltipTrigger>
|
||||||
<BaseNode className="w-full min-w-[80px] group">
|
<BaseNode className="w-full h-full group flex flex-col items-center justify-center">
|
||||||
<BaseNodeHeader className="flex-col gap-1 items-center py-3 px-4">
|
<BaseNodeHeader className="flex-col gap-1 items-center py-2 px-2">
|
||||||
<Icon size={iconPx} style={{ color }} />
|
<Icon size={iconPx} style={{ color }} />
|
||||||
<BaseNodeHeaderTitle className="text-center text-xs">
|
<BaseNodeHeaderTitle className="text-center leading-tight" style={{ fontSize: labelPx }}>
|
||||||
{nodeData.label}
|
{nodeData.label}
|
||||||
</BaseNodeHeaderTitle>
|
</BaseNodeHeaderTitle>
|
||||||
</BaseNodeHeader>
|
</BaseNodeHeader>
|
||||||
{ip && (
|
{ip && (
|
||||||
<BaseNodeContent className="items-center pt-0">
|
<BaseNodeContent className="items-center pt-0 pb-1">
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
|
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
)}
|
)}
|
||||||
<BaseHandle type="target" position={Position.Top} />
|
<BaseHandle type="target" position={Position.Top} />
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ function DiagramEditorInner() {
|
|||||||
id: n.id,
|
id: n.id,
|
||||||
type: 'device',
|
type: 'device',
|
||||||
position: n.position,
|
position: n.position,
|
||||||
|
style: n.style || { width: 120, height: 120 },
|
||||||
data: {
|
data: {
|
||||||
label: n.label,
|
label: n.label,
|
||||||
deviceType: n.type,
|
deviceType: n.type,
|
||||||
@@ -187,12 +188,15 @@ function DiagramEditorInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const data = n.data as unknown as DeviceNodeData
|
const data = n.data as unknown as DeviceNodeData
|
||||||
|
const dw = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 120)
|
||||||
|
const dh = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 120)
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: data.deviceType,
|
type: data.deviceType,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
position: n.position,
|
position: n.position,
|
||||||
properties: data.properties,
|
properties: data.properties,
|
||||||
|
style: { width: dw, height: dh },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [getNodes])
|
}, [getNodes])
|
||||||
@@ -312,6 +316,7 @@ function DiagramEditorInner() {
|
|||||||
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
type: 'device',
|
type: 'device',
|
||||||
position,
|
position,
|
||||||
|
style: { width: 120, height: 120 },
|
||||||
data: {
|
data: {
|
||||||
label,
|
label,
|
||||||
deviceType: slug,
|
deviceType: slug,
|
||||||
|
|||||||
Reference in New Issue
Block a user