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:
Pouzor
2026-05-03 21:09:14 +02:00
parent ad958feabd
commit 43761c60cb
9 changed files with 206 additions and 77 deletions
@@ -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 14 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)
})
})