Filiations : accolade franche (forme A)

Remplace le virage quadratique par une accolade à coins arrondis de rayon fixe
(24 px) : un tronc depuis l'union, une barre de fratrie, puis une descente
vers chaque enfant. Un enfant à l'aplomb de l'union descend droit. Le chemin
surligné (sélection, survol) passe par la même géométrie que la fratrie.

Tests unitaires de traceSibship (un chemin et un stroke par fratrie, même
géométrie pour un enfant seul, pas de virage à l'aplomb).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-18 20:26:34 +02:00
1 parent d58b7b779c
commit 9d9e375f58
2 files changed
+57 -54

No files matched your search

+19 -54
View File
@@ -91,15 +91,14 @@ function computeCouplePoints(p1Pos, p2Pos, unionPos, p1R, p2R, nodeRandomData, s
// Fil : terracotta clair, union en rond terracotta (le rond du logo).
// Filiation en accolade souple : l'union descend vers une ligne
// commune à la fratrie, chaque enfant s'en détache par une courbe.
// Les virages sont des quadratiques larges, pas des coins.
// Coins arrondis de rayon fixe (accolade franche, forme A).
// ============================================================
export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
// Ligne de la fratrie : sous les prénoms et années des parents, au-dessus des enfants
const BUS_DY = 100
// Rayon horizontal des virages (plafonné à la moitié de l'écart union → enfant)
const BUS_TURN = 56
// En dessous de cet écart union → enfant, pas de virage
const BUS_STRAIGHT_DX = 30
// Accolade : coins arrondis de rayon fixe (forme A, choisie par Patch), écart minimal en dessous duquel l'enfant descend droit
const SIB_R = 24
const SIB_STRAIGHT_DX = 12
function busY(sourceId, sy) {
let h = 0
@@ -108,65 +107,29 @@ function busY(sourceId, sy) {
return sy + BUS_DY + (Math.abs(h) % 3) * 4
}
function traceBus(ctx, sx, sy, by, ex, ey) {
const dx = ex - sx
ctx.beginPath()
ctx.moveTo(sx, sy)
// Enfant presque sous l'union (enfant unique, décalage aléatoire de quelques px) : trait direct, pas de S
if (Math.abs(dx) < BUS_STRAIGHT_DX) { ctx.lineTo(ex, ey); ctx.stroke(); return }
const dir = Math.sign(dx)
const turn = Math.min(BUS_TURN, Math.abs(dx) / 2)
// Descente de l'union, virage large vers la ligne de la fratrie
const drop = Math.min(by - sy, 64)
ctx.lineTo(sx, by - drop)
ctx.quadraticCurveTo(sx, by, sx + dir * turn, by)
// Ligne de la fratrie, puis courbe jusqu'au portrait de l'enfant
ctx.lineTo(ex - dir * turn, by)
ctx.quadraticCurveTo(ex, by, ex, ey)
ctx.stroke()
}
// Une union, toute sa fratrie : une seule trajectoire (un tronc, une accolade, un trait par enfant).
// Un chemin par enfant aurait un rayon de virage propre, donc des brins qui se séparent sous l'union.
// Le rayon est commun à chaque côté (le plus petit écart du côté), et le tout part dans un seul stroke
// pour que les parties superposées ne s'additionnent pas en alpha.
function traceSibship(ctx, sourceId, sourcePos, items) {
// Une union, toute sa fratrie : un seul tracé. Tronc depuis l'union jusqu'à la barre, coins arrondis
// de même rayon à chaque jonction, puis descente vers chaque enfant. Chaque enfant a sa propre trajectoire
// (sur la même barre), donc le surlignage du chemin suit exactement la même géométrie.
export function traceSibship(ctx, sourceId, sourcePos, items) {
const sx = sourcePos.cx, sy = sourcePos.cy
const by = busY(sourceId, sy)
const drop = Math.min(by - sy, 64)
const turnFor = (dir) => {
let t = BUS_TURN
for (const it of items) {
const dx = it.cx - sx
if (Math.sign(dx) === dir && Math.abs(dx) >= BUS_STRAIGHT_DX) t = Math.min(t, Math.abs(dx) / 2)
}
return t
}
const turns = { '-1': turnFor(-1), '1': turnFor(1) }
const rr = Math.min(SIB_R, by - sy - 2)
ctx.beginPath()
for (const { cx: ex, cy: ey } of items) {
const dx = ex - sx
ctx.moveTo(sx, sy)
ctx.lineTo(sx, by - drop)
// Enfant presque sous l'union : descente droite depuis la barre, pas de second brin depuis l'union
if (Math.abs(dx) < BUS_STRAIGHT_DX) { ctx.lineTo(ex, by); ctx.lineTo(ex, ey); continue }
if (Math.abs(dx) < SIB_STRAIGHT_DX) { ctx.lineTo(sx, by); ctx.lineTo(ex, by); ctx.lineTo(ex, ey); continue }
const dir = Math.sign(dx)
const turn = turns[dir]
ctx.quadraticCurveTo(sx, by, sx + dir * turn, by)
ctx.lineTo(ex - dir * turn, by)
ctx.quadraticCurveTo(ex, by, ex, ey)
const k = Math.min(rr, Math.abs(dx) / 2)
ctx.lineTo(sx, by - k)
ctx.arcTo(sx, by, sx + dir * k, by, k)
ctx.lineTo(ex - dir * k, by)
ctx.arcTo(ex, by, ex, ey, k)
ctx.lineTo(ex, ey)
}
ctx.stroke()
}
// Trace une filiation union → enfant. progress < 1 : entrée en fondu
function traceFiliation(ctx, sourceId, sourcePos, targetPos, childR, progress) {
const a = ctx.globalAlpha
ctx.globalAlpha = a * progress
traceBus(ctx, sourcePos.cx, sourcePos.cy, busY(sourceId, sourcePos.cy), targetPos.cx, targetPos.cy - childR)
ctx.globalAlpha = a
}
// ============================================================
// Courbes de couple (Bézier)
// ============================================================
@@ -411,7 +374,9 @@ export function drawPathHighlight(ctx, {
ctx.lineCap = 'round'
ctx.globalAlpha = 1
traceFiliation(ctx, edge.sources[0], sourcePos, targetPos, childR, hp)
ctx.globalAlpha = hp
traceSibship(ctx, edge.sources[0], sourcePos, [{ cx: targetPos.cx, cy: targetPos.cy - childR }])
ctx.globalAlpha = 1
})
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { traceSibship } from './renderers'
// Contexte canvas minimal qui enregistre les appels de tracé
const recorder = () => {
const calls = []
const ctx = new Proxy({}, { get: (_, name) => (...args) => { calls.push([name, ...args]) } })
return { ctx, calls, count: (n) => calls.filter(c => c[0] === n).length }
}
const src = { cx: 500, cy: 300 }
describe('traceSibship (accolade franche)', () => {
it('trace toute la fratrie en un seul chemin et un seul stroke', () => {
const { ctx, count } = recorder()
traceSibship(ctx, 'u-1', src, [{ cx: 200, cy: 480 }, { cx: 420, cy: 480 }, { cx: 800, cy: 480 }])
expect(count('beginPath')).toBe(1)
expect(count('stroke')).toBe(1)
expect(count('moveTo')).toBe(3)
})
it('un enfant seul rejoint la barre avec la même géométrie que dans la fratrie', () => {
const child = { cx: 800, cy: 480 }
const solo = recorder()
traceSibship(solo.ctx, 'u-1', src, [child])
const group = recorder()
traceSibship(group.ctx, 'u-1', src, [child, { cx: 200, cy: 480 }])
const soloArcs = solo.calls.filter(c => c[0] === 'arcTo')
const groupArcs = group.calls.filter(c => c[0] === 'arcTo').slice(0, 2)
expect(soloArcs.length).toBe(2)
expect(groupArcs).toEqual(soloArcs)
})
it('un enfant à l\'aplomb de l\'union descend droit, sans virage', () => {
const { ctx, count } = recorder()
traceSibship(ctx, 'u-1', src, [{ cx: 505, cy: 480 }])
expect(count('arcTo')).toBe(0)
})
})