diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index a582d79..897fcb9 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -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 }) } diff --git a/frontend/src/components/galaxy/renderers.test.js b/frontend/src/components/galaxy/renderers.test.js new file mode 100644 index 0000000..93ddbe0 --- /dev/null +++ b/frontend/src/components/galaxy/renderers.test.js @@ -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) + }) +})