feat(stammbaum): edge-fade mask when zoomed past fit (#692)

Permanent 4-edge mask-image gradient cues off-screen content when the tree is
zoomed in; nothing fades at fit. Replaces the dropped US-PAN-006 AC3 idle cue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:48:56 +02:00
parent 3827a9d059
commit ffc14dd2ff
2 changed files with 36 additions and 0 deletions

View File

@@ -133,6 +133,19 @@ const viewBox = $derived.by(() => {
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`; return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
}); });
// Permanent edge-fade affordance (#692, replaces US-PAN-006 AC3). When the tree
// is zoomed past fit, content is clipped at the viewport edges, so a 24px fade
// on all four edges cues that more tree exists off-screen. Zero JS beyond this
// reactive style; nothing fades at fit (z <= 1, whole tree visible).
const EDGE_FADE = 24;
const maskStyle = $derived(
panZoom.z > 1
? `-webkit-mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
`mask-image:linear-gradient(to right,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent),linear-gradient(to bottom,transparent,#000 ${EDGE_FADE}px,#000 calc(100% - ${EDGE_FADE}px),transparent);` +
`-webkit-mask-composite:source-in;mask-composite:intersect;`
: ''
);
function nodeCenter(id: string): { x: number; y: number } | null { function nodeCenter(id: string): { x: number; y: number } | null {
const p = layout.positions.get(id); const p = layout.positions.get(id);
if (!p) return null; if (!p) return null;
@@ -273,6 +286,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
role="img" role="img"
aria-label="Stammbaum" aria-label="Stammbaum"
tabindex="0" tabindex="0"
style={maskStyle}
onkeydown={handleCanvasKey} onkeydown={handleCanvasKey}
use:panZoomGestures={{ use:panZoomGestures={{
state: panZoom, state: panZoom,

View File

@@ -610,6 +610,28 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
expect(view.z).toBe(1); expect(view.z).toBe(1);
}); });
it('omits the edge-fade mask at fit (z = 1) (#692)', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
expect(document.querySelector('svg')!.getAttribute('style') ?? '').not.toContain('mask-image');
});
it('applies the edge-fade mask when zoomed past fit (#692)', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
panZoom: { x: 0, y: 0, z: 2 },
onSelect: () => {}
});
expect(document.querySelector('svg')!.getAttribute('style') ?? '').toContain('mask-image');
});
it('does not call onSelect for other keys', async () => { it('does not call onSelect for other keys', async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
render(StammbaumTree, { render(StammbaumTree, {