fix(stammbaum): anchor fresh visit to content top-left, drop space above row 1 (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
The frame-corner anchor + xMidYMid letterboxing left ~290px of empty space above the first row on desktop. Anchor to the content corner (first row / leftmost node, small margin) via cornerView, and switch the canvas to xMinYMin meet so a wide/short tree pins to the top-left instead of centring vertically. Verified live: gap above row 1 is now ~20px. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
type PanZoomState,
|
type PanZoomState,
|
||||||
clampZoom,
|
clampZoom,
|
||||||
|
clampPan,
|
||||||
recentreOn,
|
recentreOn,
|
||||||
topLeftView,
|
cornerView,
|
||||||
ZOOM_STEP_KB
|
ZOOM_STEP_KB
|
||||||
} from '$lib/person/genealogy/panZoom';
|
} from '$lib/person/genealogy/panZoom';
|
||||||
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
||||||
@@ -143,10 +144,30 @@ const railRows = $derived(
|
|||||||
.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
|
.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
|
||||||
);
|
);
|
||||||
|
|
||||||
// A fresh visit (no shared URL state) lands on the tree's top-left corner rather
|
// A fresh visit (no shared URL state) lands on the tree's content top-left
|
||||||
// than its centre (#692). Runs once after layout is available.
|
// rather than its centre (#692). Anchors to the first row / leftmost node (not
|
||||||
|
// the padded frame corner, which would leave empty space above row 1), with a
|
||||||
|
// small margin. Runs once after layout is available.
|
||||||
|
const ANCHOR_MARGIN = 24;
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (anchorTopLeft) onPanZoom(topLeftView(baseDims.w, baseDims.h, panZoom.z));
|
if (!anchorTopLeft) return;
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
for (const pos of layout.positions.values()) {
|
||||||
|
minX = Math.min(minX, pos.x);
|
||||||
|
minY = Math.min(minY, pos.y);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minX)) return; // no nodes
|
||||||
|
const target = cornerView(
|
||||||
|
minX - ANCHOR_MARGIN,
|
||||||
|
minY - ANCHOR_MARGIN,
|
||||||
|
baseCentre.x,
|
||||||
|
baseCentre.y,
|
||||||
|
baseDims.w,
|
||||||
|
baseDims.h,
|
||||||
|
panZoom.z
|
||||||
|
);
|
||||||
|
onPanZoom(clampPan(target, baseDims.w, baseDims.h));
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewBox = $derived.by(() => {
|
const viewBox = $derived.by(() => {
|
||||||
@@ -309,7 +330,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
<svg
|
<svg
|
||||||
bind:this={svgEl}
|
bind:this={svgEl}
|
||||||
viewBox={viewBox}
|
viewBox={viewBox}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMinYMin meet"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Stammbaum"
|
aria-label="Stammbaum"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
stepInertia,
|
stepInertia,
|
||||||
recentreOn,
|
recentreOn,
|
||||||
clampPan,
|
clampPan,
|
||||||
topLeftView,
|
cornerView,
|
||||||
lerpView,
|
lerpView,
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
DEFAULT_ZOOM,
|
DEFAULT_ZOOM,
|
||||||
@@ -208,15 +208,17 @@ describe('clampPan', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('topLeftView', () => {
|
describe('cornerView', () => {
|
||||||
it('aligns the viewBox top-left with the tree corner at the given zoom', () => {
|
// Frame 0..1000 × 0..800, centre (500, 400).
|
||||||
expect(topLeftView(1000, 800, 2)).toEqual({ x: -250, y: -200, z: 2 });
|
it('puts the viewBox top-left on the target SVG point', () => {
|
||||||
|
// Target = content top-left at (100, 80), z=2 → viewBox is 500×400.
|
||||||
|
expect(cornerView(100, 80, 500, 400, 1000, 800, 2)).toEqual({ x: -150, y: -120, z: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sits exactly at the negative clamp limit (the corner is reachable)', () => {
|
it('reduces to the frame corner when the target is the frame top-left', () => {
|
||||||
const v = topLeftView(1000, 800, 3);
|
// Target = frame top-left (0, 0) → most-negative (corner) pan.
|
||||||
// clampPan must leave the corner view untouched (it is on the boundary).
|
const v = cornerView(0, 0, 500, 400, 1000, 800, 3);
|
||||||
expect(clampPan(v, 1000, 800)).toEqual(v);
|
expect(clampPan(v, 1000, 800)).toEqual(v); // on the clamp boundary
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -196,12 +196,25 @@ export function clampPan(state: PanZoomState, baseW: number, baseH: number): Pan
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The view whose viewBox top-left aligns with the tree's top-left corner at zoom
|
* The view whose viewBox top-left lands on the SVG point (`targetX`, `targetY`)
|
||||||
* `z` (the landing view for a fresh visit). This is the pan at the most-negative
|
* at zoom `z` — used to anchor a fresh visit to the tree's content corner.
|
||||||
* edge of the pannable range, i.e. `-clampPan` limit on each axis.
|
* Pass the content bounding-box top-left (not the padded frame corner) so the
|
||||||
|
* first row sits near the top with no empty slack above it.
|
||||||
*/
|
*/
|
||||||
export function topLeftView(baseW: number, baseH: number, z: number): PanZoomState {
|
export function cornerView(
|
||||||
return { x: (baseW / z - baseW) / 2, y: (baseH / z - baseH) / 2, z };
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
baseCentreX: number,
|
||||||
|
baseCentreY: number,
|
||||||
|
baseW: number,
|
||||||
|
baseH: number,
|
||||||
|
z: number
|
||||||
|
): PanZoomState {
|
||||||
|
return {
|
||||||
|
x: targetX - baseCentreX + baseW / z / 2,
|
||||||
|
y: targetY - baseCentreY + baseH / z / 2,
|
||||||
|
z
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user