feat(handles): allow 1-48 bottom connection points via slider
Replaces the 4-option dropdown with a 1..48 slider. Existing handle IDs
('bottom', 'bottom-2'..'bottom-4') are preserved and the hand-tuned pixel
positions for counts 1..4 are locked in (regression-tested) so saved
canvases render identically. Counts >=5 use uniform spacing; node card
auto-grows in width so handles stay clickable. Existing edge-remap on
shrink scales unchanged thanks to its index-based loop.
ha-relevant: yes
This commit is contained in:
@@ -1,51 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
BOTTOM_HANDLE_IDS,
|
||||
BOTTOM_HANDLE_POSITIONS,
|
||||
MIN_BOTTOM_HANDLES,
|
||||
MAX_BOTTOM_HANDLES,
|
||||
bottomHandleId,
|
||||
bottomHandlePositions,
|
||||
clampBottomHandles,
|
||||
normalizeHandle,
|
||||
removedBottomHandleIds,
|
||||
} from '../handleUtils'
|
||||
|
||||
describe('BOTTOM_HANDLE_IDS', () => {
|
||||
describe('bottomHandleId', () => {
|
||||
it('first id is always "bottom" for backward compatibility', () => {
|
||||
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
|
||||
expect(bottomHandleId(0)).toBe('bottom')
|
||||
})
|
||||
|
||||
it('has ids for 1–4 handles', () => {
|
||||
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
||||
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
||||
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
|
||||
expect(bottomHandleId(1)).toBe('bottom-2')
|
||||
expect(bottomHandleId(2)).toBe('bottom-3')
|
||||
expect(bottomHandleId(3)).toBe('bottom-4')
|
||||
expect(bottomHandleId(11)).toBe('bottom-12')
|
||||
expect(bottomHandleId(47)).toBe('bottom-48')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BOTTOM_HANDLE_POSITIONS', () => {
|
||||
describe('clampBottomHandles', () => {
|
||||
it('clamps below MIN to MIN', () => {
|
||||
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('clamps above MAX to MAX', () => {
|
||||
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('returns MIN for non-finite or non-number', () => {
|
||||
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('floors fractional values', () => {
|
||||
expect(clampBottomHandles(3.9)).toBe(3)
|
||||
})
|
||||
|
||||
it('passes valid integers through', () => {
|
||||
expect(clampBottomHandles(1)).toBe(1)
|
||||
expect(clampBottomHandles(24)).toBe(24)
|
||||
expect(clampBottomHandles(48)).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bottomHandlePositions — backward-compat lock for 1..4', () => {
|
||||
// These exact arrays are the pre-multi-handle hand-tuned positions.
|
||||
// Existing user canvases depend on them — do NOT change.
|
||||
it('1 handle is centered at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
||||
expect(bottomHandlePositions(1)).toEqual([50])
|
||||
})
|
||||
|
||||
it('2 handles are symmetric', () => {
|
||||
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
||||
expect(a).toBeLessThan(50)
|
||||
expect(b).toBeGreaterThan(50)
|
||||
expect(a + b).toBe(100)
|
||||
it('2 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(2)).toEqual([25, 75])
|
||||
})
|
||||
|
||||
it('3 handles include a center at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
||||
it('3 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
|
||||
})
|
||||
|
||||
it('4 handles are evenly spaced', () => {
|
||||
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
||||
expect(pos).toHaveLength(4)
|
||||
// All values should be between 0 and 100 exclusive
|
||||
it('4 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
|
||||
})
|
||||
})
|
||||
|
||||
describe('bottomHandlePositions — uniform spacing for ≥5', () => {
|
||||
it('returns count entries', () => {
|
||||
expect(bottomHandlePositions(5)).toHaveLength(5)
|
||||
expect(bottomHandlePositions(12)).toHaveLength(12)
|
||||
expect(bottomHandlePositions(48)).toHaveLength(48)
|
||||
})
|
||||
|
||||
it('all positions strictly between 0 and 100', () => {
|
||||
const pos = bottomHandlePositions(48)
|
||||
pos.forEach((p) => {
|
||||
expect(p).toBeGreaterThan(0)
|
||||
expect(p).toBeLessThan(100)
|
||||
})
|
||||
// Positions should be strictly increasing
|
||||
})
|
||||
|
||||
it('positions are strictly increasing and uniform', () => {
|
||||
const pos = bottomHandlePositions(12)
|
||||
for (let i = 1; i < pos.length; i++) {
|
||||
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
||||
}
|
||||
const step = 100 / 13
|
||||
expect(pos[0]).toBeCloseTo(step, 5)
|
||||
expect(pos[11]).toBeCloseTo(step * 12, 5)
|
||||
})
|
||||
|
||||
it('clamps out-of-range counts before computing', () => {
|
||||
expect(bottomHandlePositions(0)).toEqual([50])
|
||||
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,16 +117,10 @@ describe('normalizeHandle', () => {
|
||||
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
||||
})
|
||||
|
||||
it('maps bottom-2-t → bottom-2', () => {
|
||||
it('maps bottom-N-t → bottom-N for any N', () => {
|
||||
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
||||
})
|
||||
|
||||
it('maps bottom-3-t → bottom-3', () => {
|
||||
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
|
||||
})
|
||||
|
||||
it('maps bottom-4-t → bottom-4', () => {
|
||||
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
|
||||
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
|
||||
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
|
||||
})
|
||||
|
||||
it('passes through non-stub handles unchanged', () => {
|
||||
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
|
||||
})
|
||||
|
||||
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('3 → 2 removes only bottom-3', () => {
|
||||
const removed = removedBottomHandleIds(3, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3']))
|
||||
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
|
||||
})
|
||||
|
||||
it('never removes "bottom" (index 0)', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false)
|
||||
})
|
||||
|
||||
// Regression: scaling the cap from 4 → 48 must not break the remap loop.
|
||||
it('scales to high counts (48 → 1 removes 47 ids)', () => {
|
||||
const removed = removedBottomHandleIds(48, 1)
|
||||
expect(removed.size).toBe(47)
|
||||
expect(removed.has('bottom-2')).toBe(true)
|
||||
expect(removed.has('bottom-48')).toBe(true)
|
||||
expect(removed.has('bottom')).toBe(false)
|
||||
})
|
||||
|
||||
it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => {
|
||||
const removed = removedBottomHandleIds(10, 2)
|
||||
expect(removed.size).toBe(8)
|
||||
expect(removed.has('bottom-2')).toBe(false)
|
||||
expect(removed.has('bottom-3')).toBe(true)
|
||||
expect(removed.has('bottom-10')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||
import { normalizeHandle } from '@/utils/handleUtils'
|
||||
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.measured?.width ?? n.width ?? null,
|
||||
height: n.measured?.height ?? n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function deserializeApiNode(
|
||||
id: n.id,
|
||||
type: normalizedType,
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: { ...n, type: normalizedType } as unknown as NodeData,
|
||||
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
|
||||
@@ -2,20 +2,44 @@
|
||||
* Bottom handle configuration for multi-handle nodes.
|
||||
*
|
||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
||||
*
|
||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
||||
*/
|
||||
|
||||
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
|
||||
export const MIN_BOTTOM_HANDLES = 1
|
||||
export const MAX_BOTTOM_HANDLES = 48
|
||||
|
||||
/** Left % position for each handle slot, per count. */
|
||||
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
||||
1: [50],
|
||||
2: [25, 75],
|
||||
3: [20, 50, 80],
|
||||
4: [15, 38, 62, 85],
|
||||
/** Returns the source handle ID at a given slot index. */
|
||||
export function bottomHandleId(idx: number): string {
|
||||
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
||||
}
|
||||
|
||||
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
|
||||
export function clampBottomHandles(n: unknown): number {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
|
||||
const i = Math.floor(n)
|
||||
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
|
||||
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
|
||||
return i
|
||||
}
|
||||
|
||||
/**
|
||||
* Left % positions for each handle slot.
|
||||
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
|
||||
* positions on canvases saved before the multi-handle expansion.
|
||||
* Counts ≥ 5 use uniform spacing.
|
||||
*/
|
||||
export function bottomHandlePositions(count: number): number[] {
|
||||
const c = clampBottomHandles(count)
|
||||
switch (c) {
|
||||
case 1: return [50]
|
||||
case 2: return [25, 75]
|
||||
case 3: return [20, 50, 80]
|
||||
case 4: return [15, 38, 62, 85]
|
||||
default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null {
|
||||
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||
const removed = new Set<string>()
|
||||
for (let i = newCount; i < oldCount; i++) {
|
||||
removed.add(BOTTOM_HANDLE_IDS[i])
|
||||
removed.add(bottomHandleId(i))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user