Merge pull request #172 from Pouzor/feat/switch-ports-64-fibre-edge
feat(canvas): switch port cap 64 + port numbers (#20) and fibre edge type (#21)
This commit is contained in:
@@ -59,7 +59,10 @@ vi.mock('@/utils/propertyIcons', () => ({
|
|||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||||
bottomHandlePositions: () => [50],
|
bottomHandlePositions: (count: number) => {
|
||||||
|
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
|
||||||
|
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
||||||
|
},
|
||||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -171,6 +174,29 @@ describe('BaseNode — properties rendering', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BaseNode — port numbers (issue #20)', () => {
|
||||||
|
it('renders a number above each bottom handle when show_port_numbers is on', () => {
|
||||||
|
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
|
||||||
|
expect(screen.getByText('1')).toBeDefined()
|
||||||
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
|
expect(screen.getByText('3')).toBeDefined()
|
||||||
|
expect(screen.getByText('4')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render port numbers when show_port_numbers is off', () => {
|
||||||
|
renderBaseNode({ bottom_handles: 4 })
|
||||||
|
expect(screen.queryByText('1')).toBeNull()
|
||||||
|
expect(screen.queryByText('4')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('numbers match the handle count', () => {
|
||||||
|
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
|
||||||
|
expect(screen.getByText('1')).toBeDefined()
|
||||||
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
|
expect(screen.queryByText('3')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('BaseNode — services visibility toggle', () => {
|
describe('BaseNode — services visibility toggle', () => {
|
||||||
it('does not render service toggle button on the node', () => {
|
it('does not render service toggle button on the node', () => {
|
||||||
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
|
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { edgeTypes } from '../edgeTypes'
|
||||||
|
import { EDGE_TYPE_LABELS, type EdgeType } from '@/types'
|
||||||
|
|
||||||
|
describe('edgeTypes registry', () => {
|
||||||
|
// Regression (issue #21): an EdgeType missing here makes React Flow fall back
|
||||||
|
// to its built-in default edge — grey, unstyled, ignoring custom_color.
|
||||||
|
it('registers a component for every EdgeType', () => {
|
||||||
|
for (const type of Object.keys(EDGE_TYPE_LABELS) as EdgeType[]) {
|
||||||
|
expect(edgeTypes[type as keyof typeof edgeTypes]).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers fibre', () => {
|
||||||
|
expect(edgeTypes.fibre).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,4 +7,5 @@ export const edgeTypes = {
|
|||||||
vlan: HomelableEdge,
|
vlan: HomelableEdge,
|
||||||
virtual: HomelableEdge,
|
virtual: HomelableEdge,
|
||||||
cluster: HomelableEdge,
|
cluster: HomelableEdge,
|
||||||
|
fibre: HomelableEdge,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
vlan: { strokeWidth: 2.5 },
|
vlan: { strokeWidth: 2.5 },
|
||||||
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
||||||
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
||||||
|
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
|
||||||
}
|
}
|
||||||
|
|
||||||
const customColor = data?.custom_color as string | undefined
|
const customColor = data?.custom_color as string | undefined
|
||||||
|
|||||||
@@ -254,6 +254,20 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const targetId = `${sourceId}-t`
|
const targetId = `${sourceId}-t`
|
||||||
return (
|
return (
|
||||||
<span key={sourceId}>
|
<span key={sourceId}>
|
||||||
|
{data.show_port_numbers && (
|
||||||
|
<span
|
||||||
|
className="absolute font-mono leading-none pointer-events-none select-none"
|
||||||
|
style={{
|
||||||
|
left: `${leftPct}%`,
|
||||||
|
bottom: 3,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: 7,
|
||||||
|
color: theme.colors.nodeSubtextColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
|
|||||||
'generic',
|
'generic',
|
||||||
]
|
]
|
||||||
|
|
||||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||||
|
|
||||||
const NODE_ICONS: Record<string, LucideIcon> = {
|
const NODE_ICONS: Record<string, LucideIcon> = {
|
||||||
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||||
|
|||||||
@@ -513,6 +513,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!form.show_port_numbers}
|
||||||
|
onClick={() => set('show_port_numbers', !form.show_port_numbers)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Toggle port numbers"
|
||||||
|
style={{ background: form.show_port_numbers ? '#ff6e00' : '#30363d' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
||||||
|
style={{ left: form.show_port_numbers ? 'calc(100% - 18px)' : '2px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
|
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('round-trips the fibre type through submit (issue #21)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'fibre' }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].type).toBe('fibre')
|
||||||
|
})
|
||||||
|
|
||||||
// ── VLAN ID field ─────────────────────────────────────────────────────────
|
// ── VLAN ID field ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show VLAN ID field for ethernet type', () => {
|
it('does not show VLAN ID field for ethernet type', () => {
|
||||||
|
|||||||
@@ -416,20 +416,30 @@ describe('NodeModal', () => {
|
|||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('supports the full 1..48 range', () => {
|
it('supports the full 1..64 range (issue #20)', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
expect(slider.min).toBe('1')
|
expect(slider.min).toBe('1')
|
||||||
expect(slider.max).toBe('48')
|
expect(slider.max).toBe('64')
|
||||||
fireEvent.change(slider, { target: { value: '48' } })
|
fireEvent.change(slider, { target: { value: '52' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps pre-filled out-of-range values into [1,48]', () => {
|
it('clamps pre-filled out-of-range values into [1,64]', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
expect(slider.value).toBe('48')
|
expect(slider.value).toBe('64')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
const toggle = screen.getByLabelText('Toggle port numbers')
|
||||||
|
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).show_port_numbers).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Zigbee nodes ──────────────────────────────────────────────────────
|
// ── Zigbee nodes ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('STATUS_COLORS', () => {
|
|||||||
|
|
||||||
describe('EDGE_TYPE_LABELS', () => {
|
describe('EDGE_TYPE_LABELS', () => {
|
||||||
it('has an entry for every edge type', () => {
|
it('has an entry for every edge type', () => {
|
||||||
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||||
expectedTypes.forEach((t) => {
|
expectedTypes.forEach((t) => {
|
||||||
expect(EDGE_TYPE_LABELS).toHaveProperty(t)
|
expect(EDGE_TYPE_LABELS).toHaveProperty(t)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export type TextPosition =
|
|||||||
| 'bottom-center'
|
| 'bottom-center'
|
||||||
| 'bottom-right'
|
| 'bottom-right'
|
||||||
|
|
||||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre'
|
||||||
|
|
||||||
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
||||||
|
|
||||||
@@ -106,8 +106,10 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
*/
|
*/
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
/** Number of bottom connection points, 1..64. Default 1 (centered). */
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
|
/** Show a port number (1..N) above each bottom connection point. */
|
||||||
|
show_port_numbers?: boolean
|
||||||
/** Text node content (type === 'text') */
|
/** Text node content (type === 'text') */
|
||||||
text_content?: string
|
text_content?: string
|
||||||
}
|
}
|
||||||
@@ -173,6 +175,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
|||||||
vlan: 'VLAN',
|
vlan: 'VLAN',
|
||||||
virtual: 'Virtual',
|
virtual: 'Virtual',
|
||||||
cluster: 'Cluster',
|
cluster: 'Cluster',
|
||||||
|
fibre: 'Fibre',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTypeStyle {
|
export interface NodeTypeStyle {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import { EDGE_DEFAULT_COLORS } from '../edgeColors'
|
import { EDGE_DEFAULT_COLORS } from '../edgeColors'
|
||||||
import type { EdgeType } from '@/types'
|
import type { EdgeType } from '@/types'
|
||||||
|
|
||||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||||
|
|
||||||
describe('EDGE_DEFAULT_COLORS', () => {
|
describe('EDGE_DEFAULT_COLORS', () => {
|
||||||
it('has an entry for every EdgeType', () => {
|
it('has an entry for every EdgeType', () => {
|
||||||
@@ -36,4 +36,8 @@ describe('EDGE_DEFAULT_COLORS', () => {
|
|||||||
it('cluster default is proxmox orange', () => {
|
it('cluster default is proxmox orange', () => {
|
||||||
expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00')
|
expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fibre default is bright cyan', () => {
|
||||||
|
expect(EDGE_DEFAULT_COLORS.fibre).toBe('#22d3ee')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ describe('exportCanvasToYaml', () => {
|
|||||||
expect(entryA).not.toHaveProperty('clusterR')
|
expect(entryA).not.toHaveProperty('clusterR')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('serializes a fibre edge with linkType "fibre" (issue #21)', () => {
|
||||||
|
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||||
|
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
const edge = makeEdge('e1', 'sw', 's1', { type: 'fibre', label: 'sfp0' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'Switch')!
|
||||||
|
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'fibre', linkLabel: 'sfp0' }])
|
||||||
|
})
|
||||||
|
|
||||||
it('serializes multiple outgoing edges as links array', () => {
|
it('serializes multiple outgoing edges as links array', () => {
|
||||||
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||||
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ describe('clampBottomHandles', () => {
|
|||||||
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
|
||||||
|
expect(MAX_BOTTOM_HANDLES).toBeGreaterThanOrEqual(52)
|
||||||
|
})
|
||||||
|
|
||||||
it('clamps above MAX to MAX', () => {
|
it('clamps above MAX to MAX', () => {
|
||||||
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
|
expect(clampBottomHandles(65)).toBe(MAX_BOTTOM_HANDLES)
|
||||||
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ describe('clampBottomHandles', () => {
|
|||||||
expect(clampBottomHandles(1)).toBe(1)
|
expect(clampBottomHandles(1)).toBe(1)
|
||||||
expect(clampBottomHandles(24)).toBe(24)
|
expect(clampBottomHandles(24)).toBe(24)
|
||||||
expect(clampBottomHandles(48)).toBe(48)
|
expect(clampBottomHandles(48)).toBe(48)
|
||||||
|
expect(clampBottomHandles(52)).toBe(52)
|
||||||
|
expect(clampBottomHandles(64)).toBe(64)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,22 @@ describe('parseYamlToCanvas', () => {
|
|||||||
expect(edges[0].targetHandle).toBe('top-t')
|
expect(edges[0].targetHandle).toBe('top-t')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('imports a fibre link type onto the edge (issue #21)', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: switch
|
||||||
|
label: "SW"
|
||||||
|
links:
|
||||||
|
- label: "SRV"
|
||||||
|
linkType: fibre
|
||||||
|
- nodeType: server
|
||||||
|
label: "SRV"
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].type).toBe('fibre')
|
||||||
|
expect(edges[0].data?.type).toBe('fibre')
|
||||||
|
})
|
||||||
|
|
||||||
it('cluster edges have cluster-right→cluster-left handles', () => {
|
it('cluster edges have cluster-right→cluster-left handles', () => {
|
||||||
const yaml = `
|
const yaml = `
|
||||||
- nodeType: proxmox
|
- nodeType: proxmox
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const NODE_TYPES: NodeType[] = [
|
|||||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||||
]
|
]
|
||||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||||
|
|
||||||
describe('THEME_ORDER', () => {
|
describe('THEME_ORDER', () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface ApiNode extends Record<string, unknown> {
|
|||||||
width?: number | null
|
width?: number | null
|
||||||
height?: number | null
|
height?: number | null
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
|
show_port_numbers?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiEdge {
|
export interface ApiEdge {
|
||||||
@@ -114,6 +115,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
width: n.measured?.width ?? n.width ?? null,
|
width: n.measured?.width ?? n.width ?? null,
|
||||||
height: n.measured?.height ?? n.height ?? null,
|
height: n.measured?.height ?? n.height ?? null,
|
||||||
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||||
|
show_port_numbers: n.data.show_port_numbers ?? false,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
|||||||
vlan: '#00d4ff',
|
vlan: '#00d4ff',
|
||||||
virtual: '#8b949e',
|
virtual: '#8b949e',
|
||||||
cluster: '#ff6e00',
|
cluster: '#ff6e00',
|
||||||
|
fibre: '#22d3ee',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
* Bottom handle configuration for multi-handle nodes.
|
* Bottom handle configuration for multi-handle nodes.
|
||||||
*
|
*
|
||||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
|
||||||
*
|
*
|
||||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MIN_BOTTOM_HANDLES = 1
|
export const MIN_BOTTOM_HANDLES = 1
|
||||||
export const MAX_BOTTOM_HANDLES = 48
|
export const MAX_BOTTOM_HANDLES = 64
|
||||||
|
|
||||||
/** Returns the source handle ID at a given slot index. */
|
/** Returns the source handle ID at a given slot index. */
|
||||||
export function bottomHandleId(idx: number): string {
|
export function bottomHandleId(idx: number): string {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
vlan: '#00d4ff',
|
vlan: '#00d4ff',
|
||||||
virtual: '#8b949e',
|
virtual: '#8b949e',
|
||||||
cluster: '#ff6e00',
|
cluster: '#ff6e00',
|
||||||
|
fibre: '#22d3ee',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00d4ff',
|
edgeSelectedColor: '#00d4ff',
|
||||||
edgeLabelBackground:'#161b22',
|
edgeLabelBackground:'#161b22',
|
||||||
@@ -149,6 +150,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
vlan: '#22d3ee',
|
vlan: '#22d3ee',
|
||||||
virtual: '#6b7280',
|
virtual: '#6b7280',
|
||||||
cluster: '#fb923c',
|
cluster: '#fb923c',
|
||||||
|
fibre: '#06b6d4',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#22d3ee',
|
edgeSelectedColor: '#22d3ee',
|
||||||
edgeLabelBackground:'#111111',
|
edgeLabelBackground:'#111111',
|
||||||
@@ -212,6 +214,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
vlan: '#0284c7',
|
vlan: '#0284c7',
|
||||||
virtual: '#9ca3af',
|
virtual: '#9ca3af',
|
||||||
cluster: '#ea580c',
|
cluster: '#ea580c',
|
||||||
|
fibre: '#0891b2',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#0284c7',
|
edgeSelectedColor: '#0284c7',
|
||||||
edgeLabelBackground:'#ffffff',
|
edgeLabelBackground:'#ffffff',
|
||||||
@@ -275,6 +278,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
vlan: '#00ffff',
|
vlan: '#00ffff',
|
||||||
virtual: '#8888cc',
|
virtual: '#8888cc',
|
||||||
cluster: '#ff8800',
|
cluster: '#ff8800',
|
||||||
|
fibre: '#00e5ff',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00ffff',
|
edgeSelectedColor: '#00ffff',
|
||||||
edgeLabelBackground:'#0a0a1a',
|
edgeLabelBackground:'#0a0a1a',
|
||||||
@@ -338,6 +342,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
vlan: '#00cc33',
|
vlan: '#00cc33',
|
||||||
virtual: '#004400',
|
virtual: '#004400',
|
||||||
cluster: '#33ff66',
|
cluster: '#33ff66',
|
||||||
|
fibre: '#00ffcc',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00ff41',
|
edgeSelectedColor: '#00ff41',
|
||||||
edgeLabelBackground:'#001100',
|
edgeLabelBackground:'#001100',
|
||||||
@@ -401,6 +406,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
vlan: '#00d4ff',
|
vlan: '#00d4ff',
|
||||||
virtual: '#8b949e',
|
virtual: '#8b949e',
|
||||||
cluster: '#ff6e00',
|
cluster: '#ff6e00',
|
||||||
|
fibre: '#22d3ee',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00d4ff',
|
edgeSelectedColor: '#00d4ff',
|
||||||
edgeLabelBackground:'#161b22',
|
edgeLabelBackground:'#161b22',
|
||||||
|
|||||||
Reference in New Issue
Block a user