Compare commits
5 Commits
ba053b3c23
...
c1dd6d299f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1dd6d299f | ||
|
|
a458d3508b | ||
|
|
bb2a89da58 | ||
|
|
578bebbd8b | ||
|
|
7e859252a3 |
@@ -125,7 +125,7 @@ _Not to be confused with [parented](#parented-layout)_ — loose is the absence
|
|||||||
|
|
||||||
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
|
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
|
||||||
|
|
||||||
**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-026](adr/026-stammbaum-custom-viewbox-pan-zoom.md).
|
**pan/zoom view state** `[#692]` — the `{ x, y, z }` triple (`PanZoomState` in `frontend/src/lib/person/genealogy/panZoom.ts`) describing the Stammbaum canvas position: `x`/`y` are pan offsets applied to the SVG viewBox centre, `z` is the zoom factor (clamped 0.25–3.0). Mirrored into the shareable URL as `?cx&cy&z` and seeded server-side from those params. See [ADR-027](adr/027-stammbaum-custom-viewbox-pan-zoom.md).
|
||||||
|
|
||||||
**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
|
**fit-to-screen** `[user-facing, #692]` — the Stammbaum control (`⤢`) and initial state that frames the whole tree in the viewport. Because the base viewBox already encloses the layout at `z=1`, fit-to-screen is simply the default view `{x:0, y:0, z:1}`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ADR-026 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library
|
# ADR-027 — Stammbaum pan/zoom is a custom viewBox layer, not a third-party library
|
||||||
|
|
||||||
**Date:** 2026-05-29
|
**Date:** 2026-05-29
|
||||||
**Status:** Accepted
|
**Status:** Accepted
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PanZoomState } from '$lib/person/genealogy/panZoom';
|
||||||
|
|
||||||
|
interface RailRow {
|
||||||
|
rank: number;
|
||||||
|
label: number;
|
||||||
|
/** Row centre in SVG user coordinates. */
|
||||||
|
centerY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The canvas SVG, read for its live screen transform. */
|
||||||
|
svg: SVGSVGElement | null;
|
||||||
|
rows: RailRow[];
|
||||||
|
/** Tracked so chip positions recompute on every pan/zoom. */
|
||||||
|
panZoom: PanZoomState;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { svg, rows, panZoom }: Props = $props();
|
||||||
|
|
||||||
|
type Chip = { rank: number; label: number; top: number; visible: boolean };
|
||||||
|
let chips = $state<Chip[]>([]);
|
||||||
|
let height = $state(0);
|
||||||
|
|
||||||
|
// Map each generation-row centre from SVG user space to a screen-y via the
|
||||||
|
// live CTM (exact under preserveAspectRatio="xMidYMid meet" — no manual
|
||||||
|
// letterbox math). Runs in an effect so it reads the CTM after the viewBox DOM
|
||||||
|
// update is flushed. `panZoom` and `height` are the recompute triggers.
|
||||||
|
$effect(() => {
|
||||||
|
// reactKey makes the effect re-run on pan, zoom and resize; the CTM reflects
|
||||||
|
// the parent's viewBox, which Svelte cannot track on its own.
|
||||||
|
const reactKey = panZoom.x + panZoom.y + panZoom.z + height;
|
||||||
|
const ctm = svg && Number.isFinite(reactKey) ? svg.getScreenCTM() : null;
|
||||||
|
const top0 = svg ? svg.getBoundingClientRect().top : 0;
|
||||||
|
// Always emit one chip per labelled row so the labels exist regardless of
|
||||||
|
// transform availability; the CTM only positions them (fallback: stacked).
|
||||||
|
chips = rows.map((row, i) => {
|
||||||
|
const top = ctm ? new DOMPoint(0, row.centerY).matrixTransform(ctm).y - top0 : 24 + i * 28;
|
||||||
|
const visible = !ctm || height <= 0 || (top >= -16 && top <= height + 16);
|
||||||
|
return { rank: row.rank, label: row.label, top, visible };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Pinned to the canvas's left edge: chips stay put horizontally while the
|
||||||
|
tree pans, and track their generation row vertically at any zoom. -->
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 z-10" bind:clientHeight={height}>
|
||||||
|
{#each chips as chip (chip.rank)}
|
||||||
|
{#if chip.visible}
|
||||||
|
<div
|
||||||
|
role="text"
|
||||||
|
aria-label={`Generation ${chip.label}`}
|
||||||
|
class="absolute left-1 -translate-y-1/2 rounded-sm border border-line bg-surface/85 px-1.5 py-0.5 font-serif text-xs text-ink-3 shadow-sm"
|
||||||
|
style="top: {chip.top}px"
|
||||||
|
>
|
||||||
|
G{chip.label}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import StammbaumGenerationRail from './StammbaumGenerationRail.svelte';
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ rank: 0, label: 0, centerY: 100 },
|
||||||
|
{ rank: 1, label: 1, centerY: 300 },
|
||||||
|
{ rank: 2, label: 3, centerY: 500 }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('StammbaumGenerationRail (#692)', () => {
|
||||||
|
it('renders one labelled chip per generation row', async () => {
|
||||||
|
render(StammbaumGenerationRail, { svg: null, rows, panZoom: { x: 0, y: 0, z: 1 } });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const labels = Array.from(document.querySelectorAll('[role="text"]')).map((el) => ({
|
||||||
|
aria: el.getAttribute('aria-label'),
|
||||||
|
text: el.textContent?.trim()
|
||||||
|
}));
|
||||||
|
expect(labels).toEqual([
|
||||||
|
{ aria: 'Generation 0', text: 'G0' },
|
||||||
|
{ aria: 'Generation 1', text: 'G1' },
|
||||||
|
{ aria: 'Generation 3', text: 'G3' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when there are no labelled rows', async () => {
|
||||||
|
render(StammbaumGenerationRail, { svg: null, rows: [], panZoom: { x: 0, y: 0, z: 1 } });
|
||||||
|
await vi.waitFor(() => expect(document.querySelectorAll('[role="text"]')).toHaveLength(0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
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';
|
||||||
|
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -97,8 +98,9 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type GutterRow = { rank: number; y: number; label: number | null };
|
type GutterRow = { rank: number; y: number; label: number | null };
|
||||||
|
// Computed on all viewports (not gated on the desktop gutter) so the pinned
|
||||||
|
// generation rail can show labels on phones too (#692).
|
||||||
const gutterRows = $derived.by<GutterRow[]>(() => {
|
const gutterRows = $derived.by<GutterRow[]>(() => {
|
||||||
if (gutterWidth === 0) return [];
|
|
||||||
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
|
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
|
||||||
const rows: GutterRow[] = [];
|
const rows: GutterRow[] = [];
|
||||||
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
|
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
|
||||||
@@ -128,6 +130,15 @@ const baseCentre = $derived({
|
|||||||
y: layout.viewY + layout.viewH / 2
|
y: layout.viewY + layout.viewH / 2
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Labelled generation rows for the pinned rail, with each row's centre in SVG
|
||||||
|
// coordinates (the rail maps these through the live screen transform).
|
||||||
|
let svgEl = $state<SVGSVGElement | null>(null);
|
||||||
|
const railRows = $derived(
|
||||||
|
gutterRows
|
||||||
|
.filter((r): r is GutterRow & { label: number } => r.label != null)
|
||||||
|
.map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 }))
|
||||||
|
);
|
||||||
|
|
||||||
const viewBox = $derived.by(() => {
|
const viewBox = $derived.by(() => {
|
||||||
const w = baseDims.w / panZoom.z;
|
const w = baseDims.w / panZoom.z;
|
||||||
const h = baseDims.h / panZoom.z;
|
const h = baseDims.h / panZoom.z;
|
||||||
@@ -278,243 +289,227 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
|
<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
|
||||||
users focus it and the keydown handler is the keyboard-only alternative to
|
<div class="relative h-full w-full">
|
||||||
touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
|
<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
users focus it and the keydown handler is the keyboard-only alternative to
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
|
||||||
<svg
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
viewBox={viewBox}
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
preserveAspectRatio="xMidYMid meet"
|
<svg
|
||||||
role="img"
|
bind:this={svgEl}
|
||||||
aria-label="Stammbaum"
|
viewBox={viewBox}
|
||||||
tabindex="0"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
style={maskStyle}
|
role="img"
|
||||||
onkeydown={handleCanvasKey}
|
aria-label="Stammbaum"
|
||||||
use:panZoomGestures={{
|
tabindex="0"
|
||||||
state: panZoom,
|
style={maskStyle}
|
||||||
baseW: baseDims.w,
|
onkeydown={handleCanvasKey}
|
||||||
baseH: baseDims.h,
|
use:panZoomGestures={{
|
||||||
baseCentreX: baseCentre.x,
|
state: panZoom,
|
||||||
baseCentreY: baseCentre.y,
|
baseW: baseDims.w,
|
||||||
reducedMotion,
|
baseH: baseDims.h,
|
||||||
onPanZoom,
|
baseCentreX: baseCentre.x,
|
||||||
onGestureStart: onActivity
|
baseCentreY: baseCentre.y,
|
||||||
}}
|
reducedMotion,
|
||||||
class="block h-full w-full"
|
onPanZoom,
|
||||||
>
|
onGestureStart: onActivity
|
||||||
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
}}
|
||||||
transparent / var(--c-gutter-stripe). aria-hidden because they carry
|
class="block h-full w-full"
|
||||||
no meaning; the row's generation is announced by the label group below. -->
|
>
|
||||||
{#each gutterRows as row, i (`stripe-${row.rank}`)}
|
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
||||||
<rect
|
transparent / var(--c-gutter-stripe), desktop only. Generation labels
|
||||||
aria-hidden="true"
|
are no longer drawn in-SVG; the pinned rail below carries them. -->
|
||||||
x={layout.viewX - gutterWidth}
|
{#if gutterVisible}
|
||||||
y={row.y - ROW_GAP / 2}
|
{#each gutterRows as row, i (`stripe-${row.rank}`)}
|
||||||
width={layout.viewW + gutterWidth}
|
<rect
|
||||||
height={NODE_H + ROW_GAP}
|
aria-hidden="true"
|
||||||
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
|
x={layout.viewX - gutterWidth}
|
||||||
/>
|
y={row.y - ROW_GAP / 2}
|
||||||
{/each}
|
width={layout.viewW + gutterWidth}
|
||||||
|
height={NODE_H + ROW_GAP}
|
||||||
<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
|
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
|
||||||
un-shifted source-truth value. Wrapped in <g role="text"> so screen
|
/>
|
||||||
readers announce "Generation three" instead of "G three". -->
|
{/each}
|
||||||
{#each gutterRows as row (`label-${row.rank}`)}
|
|
||||||
{#if row.label != null}
|
|
||||||
<g role="text" aria-label={`Generation ${row.label}`}>
|
|
||||||
<text
|
|
||||||
x={layout.viewX - gutterWidth + 12}
|
|
||||||
y={row.y + NODE_H / 2}
|
|
||||||
text-anchor="start"
|
|
||||||
dominant-baseline="middle"
|
|
||||||
font-family="var(--font-sans)"
|
|
||||||
font-size="12"
|
|
||||||
font-weight="700"
|
|
||||||
letter-spacing="0.08em"
|
|
||||||
fill="var(--c-ink-2)"
|
|
||||||
style:text-transform="uppercase"
|
|
||||||
>
|
|
||||||
G{row.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||||
bar, then short verticals from the bar to each child top. -->
|
bar, then short verticals from the bar to each child top. -->
|
||||||
{#each parentLinks.shared as group (group.key)}
|
{#each parentLinks.shared as group (group.key)}
|
||||||
{@const aCenter = nodeCenter(group.parentA)}
|
{@const aCenter = nodeCenter(group.parentA)}
|
||||||
{@const bCenter = nodeCenter(group.parentB)}
|
{@const bCenter = nodeCenter(group.parentB)}
|
||||||
{@const childCenters = group.childIds
|
{@const childCenters = group.childIds
|
||||||
.map((id) => nodeCenter(id))
|
.map((id) => nodeCenter(id))
|
||||||
.filter((c): c is { x: number; y: number } => c !== null)}
|
.filter((c): c is { x: number; y: number } => c !== null)}
|
||||||
{#if aCenter && bCenter && childCenters.length > 0}
|
{#if aCenter && bCenter && childCenters.length > 0}
|
||||||
{@const midX = (aCenter.x + bCenter.x) / 2}
|
{@const midX = (aCenter.x + bCenter.x) / 2}
|
||||||
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
||||||
{@const childTopY = childCenters[0].y - NODE_H / 2}
|
{@const childTopY = childCenters[0].y - NODE_H / 2}
|
||||||
{@const barY = (parentBottomY + childTopY) / 2}
|
{@const barY = (parentBottomY + childTopY) / 2}
|
||||||
{@const xs = childCenters.map((c) => c.x)}
|
{@const xs = childCenters.map((c) => c.x)}
|
||||||
{@const minX = Math.min(midX, ...xs)}
|
{@const minX = Math.min(midX, ...xs)}
|
||||||
{@const maxX = Math.max(midX, ...xs)}
|
{@const maxX = Math.max(midX, ...xs)}
|
||||||
<line
|
|
||||||
x1={midX}
|
|
||||||
y1={parentBottomY}
|
|
||||||
x2={midX}
|
|
||||||
y2={barY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{#if minX !== maxX}
|
|
||||||
<line
|
<line
|
||||||
x1={minX}
|
x1={midX}
|
||||||
y1={barY}
|
y1={parentBottomY}
|
||||||
x2={maxX}
|
x2={midX}
|
||||||
y2={barY}
|
y2={barY}
|
||||||
stroke="var(--c-primary)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
|
{#if minX !== maxX}
|
||||||
|
<line
|
||||||
|
x1={minX}
|
||||||
|
y1={barY}
|
||||||
|
x2={maxX}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#each childCenters as cc, i (group.childIds[i])}
|
||||||
|
<line
|
||||||
|
x1={cc.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={cc.x}
|
||||||
|
y2={childTopY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{#each childCenters as cc, i (group.childIds[i])}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
||||||
|
{#each parentLinks.single as link (link.key)}
|
||||||
|
{@const parentCenter = nodeCenter(link.parentId)}
|
||||||
|
{@const childCenter = nodeCenter(link.childId)}
|
||||||
|
{#if parentCenter && childCenter}
|
||||||
|
{@const parentBottomY = parentCenter.y + NODE_H / 2}
|
||||||
|
{@const childTopY = childCenter.y - NODE_H / 2}
|
||||||
|
{@const barY = (parentBottomY + childTopY) / 2}
|
||||||
<line
|
<line
|
||||||
x1={cc.x}
|
x1={parentCenter.x}
|
||||||
|
y1={parentBottomY}
|
||||||
|
x2={parentCenter.x}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if parentCenter.x !== childCenter.x}
|
||||||
|
<line
|
||||||
|
x1={parentCenter.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={childCenter.x}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<line
|
||||||
|
x1={childCenter.x}
|
||||||
y1={barY}
|
y1={barY}
|
||||||
x2={cc.x}
|
x2={childCenter.x}
|
||||||
y2={childTopY}
|
y2={childTopY}
|
||||||
stroke="var(--c-primary)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/if}
|
||||||
{/if}
|
{/each}
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
<!-- Spouse connectors -->
|
||||||
{#each parentLinks.single as link (link.key)}
|
{#each spouseEdges as e (e.id)}
|
||||||
{@const parentCenter = nodeCenter(link.parentId)}
|
{@const aCenter = nodeCenter(e.personId)}
|
||||||
{@const childCenter = nodeCenter(link.childId)}
|
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
||||||
{#if parentCenter && childCenter}
|
{#if aCenter && bCenter}
|
||||||
{@const parentBottomY = parentCenter.y + NODE_H / 2}
|
|
||||||
{@const childTopY = childCenter.y - NODE_H / 2}
|
|
||||||
{@const barY = (parentBottomY + childTopY) / 2}
|
|
||||||
<line
|
|
||||||
x1={parentCenter.x}
|
|
||||||
y1={parentBottomY}
|
|
||||||
x2={parentCenter.x}
|
|
||||||
y2={barY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{#if parentCenter.x !== childCenter.x}
|
|
||||||
<line
|
<line
|
||||||
x1={parentCenter.x}
|
x1={aCenter.x}
|
||||||
y1={barY}
|
y1={aCenter.y}
|
||||||
x2={childCenter.x}
|
x2={bCenter.x}
|
||||||
y2={barY}
|
y2={bCenter.y}
|
||||||
stroke="var(--c-primary)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
|
cy={(aCenter.y + bCenter.y) / 2}
|
||||||
|
r="6"
|
||||||
|
fill="var(--c-primary)"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<line
|
{/each}
|
||||||
x1={childCenter.x}
|
|
||||||
y1={barY}
|
|
||||||
x2={childCenter.x}
|
|
||||||
y2={childTopY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Spouse connectors -->
|
<!-- Nodes -->
|
||||||
{#each spouseEdges as e (e.id)}
|
{#each nodes as node (node.id)}
|
||||||
{@const aCenter = nodeCenter(e.personId)}
|
{@const pos = layout.positions.get(node.id)}
|
||||||
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
{#if pos}
|
||||||
{#if aCenter && bCenter}
|
{@const isSelected = selectedId === node.id}
|
||||||
<line
|
{@const isFocused = focusedId === node.id}
|
||||||
x1={aCenter.x}
|
<g
|
||||||
y1={aCenter.y}
|
role="button"
|
||||||
x2={bCenter.x}
|
tabindex="0"
|
||||||
y2={bCenter.y}
|
aria-label="{node.displayName}{node.birthYear || node.deathYear
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={(aCenter.x + bCenter.x) / 2}
|
|
||||||
cy={(aCenter.y + bCenter.y) / 2}
|
|
||||||
r="6"
|
|
||||||
fill="var(--c-primary)"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Nodes -->
|
|
||||||
{#each nodes as node (node.id)}
|
|
||||||
{@const pos = layout.positions.get(node.id)}
|
|
||||||
{#if pos}
|
|
||||||
{@const isSelected = selectedId === node.id}
|
|
||||||
{@const isFocused = focusedId === node.id}
|
|
||||||
<g
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="{node.displayName}{node.birthYear || node.deathYear
|
|
||||||
? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
|
? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
|
||||||
: ''}"
|
: ''}"
|
||||||
aria-expanded={isSelected}
|
aria-expanded={isSelected}
|
||||||
transform="translate({pos.x}, {pos.y})"
|
transform="translate({pos.x}, {pos.y})"
|
||||||
onclick={() => onSelect(node.id)}
|
onclick={() => onSelect(node.id)}
|
||||||
onkeydown={(e) => handleNodeKey(e, node.id)}
|
onkeydown={(e) => handleNodeKey(e, node.id)}
|
||||||
onfocus={() => (focusedId = node.id)}
|
onfocus={() => (focusedId = node.id)}
|
||||||
onblur={() => (focusedId = null)}
|
onblur={() => (focusedId = null)}
|
||||||
class="cursor-pointer focus:outline-none"
|
class="cursor-pointer focus:outline-none"
|
||||||
>
|
|
||||||
{#if isFocused}
|
|
||||||
<rect
|
|
||||||
x="-3"
|
|
||||||
y="-3"
|
|
||||||
width={NODE_W + 6}
|
|
||||||
height={NODE_H + 6}
|
|
||||||
rx="6"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--c-focus-ring)"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<rect
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
rx="4"
|
|
||||||
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{#if isSelected}
|
|
||||||
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
|
||||||
{/if}
|
|
||||||
<text
|
|
||||||
x={NODE_W / 2}
|
|
||||||
y={NODE_H / 2 - 6}
|
|
||||||
text-anchor="middle"
|
|
||||||
font-family="serif"
|
|
||||||
font-size="16"
|
|
||||||
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
|
||||||
>
|
>
|
||||||
{node.displayName}
|
{#if isFocused}
|
||||||
</text>
|
<rect
|
||||||
{#if node.birthYear || node.deathYear}
|
x="-3"
|
||||||
|
y="-3"
|
||||||
|
width={NODE_W + 6}
|
||||||
|
height={NODE_H + 6}
|
||||||
|
rx="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--c-focus-ring)"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<rect
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
rx="4"
|
||||||
|
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if isSelected}
|
||||||
|
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
||||||
|
{/if}
|
||||||
<text
|
<text
|
||||||
x={NODE_W / 2}
|
x={NODE_W / 2}
|
||||||
y={NODE_H / 2 + 12}
|
y={NODE_H / 2 - 6}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-family="sans-serif"
|
font-family="serif"
|
||||||
font-size="12"
|
font-size="16"
|
||||||
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||||
opacity={isSelected ? 0.75 : 1}
|
|
||||||
>
|
>
|
||||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
{node.displayName}
|
||||||
</text>
|
</text>
|
||||||
{/if}
|
{#if node.birthYear || node.deathYear}
|
||||||
</g>
|
<text
|
||||||
{/if}
|
x={NODE_W / 2}
|
||||||
{/each}
|
y={NODE_H / 2 + 12}
|
||||||
</svg>
|
text-anchor="middle"
|
||||||
|
font-family="sans-serif"
|
||||||
|
font-size="12"
|
||||||
|
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||||||
|
opacity={isSelected ? 0.75 : 1}
|
||||||
|
>
|
||||||
|
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<StammbaumGenerationRail svg={svgEl} rows={railRows} panZoom={panZoom} />
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -831,10 +831,13 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('StammbaumTree generation gutter (#689)', () => {
|
describe('StammbaumTree generation rail (#689, #692)', () => {
|
||||||
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
|
const railLabels = () =>
|
||||||
// showGutter overrides the matchMedia detection so the test never
|
Array.from(document.querySelectorAll('[role="text"]')).map((el) =>
|
||||||
// depends on the vitest-browser iframe viewport width.
|
el.getAttribute('aria-label')
|
||||||
|
);
|
||||||
|
|
||||||
|
it('renders a G{n} label per occupied generation row on the pinned rail', async () => {
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
|
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
|
||||||
@@ -843,35 +846,37 @@ describe('StammbaumTree generation gutter (#689)', () => {
|
|||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
panZoom: { x: 0, y: 0, z: 1 },
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {},
|
onSelect: () => {}
|
||||||
showGutter: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
|
await vi.waitFor(() => {
|
||||||
g.getAttribute('aria-label')
|
const labels = railLabels();
|
||||||
);
|
expect(labels).toContain('Generation 2');
|
||||||
expect(labels).toContain('Generation 2');
|
expect(labels).toContain('Generation 3');
|
||||||
expect(labels).toContain('Generation 3');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
|
it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => {
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
panZoom: { x: 0, y: 0, z: 1 },
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {},
|
onSelect: () => {}
|
||||||
showGutter: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
|
await vi.waitFor(() => {
|
||||||
(g) => g.getAttribute('aria-label') === 'Generation 3'
|
const g3 = Array.from(document.querySelectorAll('[role="text"]')).find(
|
||||||
);
|
(el) => el.getAttribute('aria-label') === 'Generation 3'
|
||||||
expect(g3).toBeDefined();
|
);
|
||||||
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
|
expect(g3).toBeDefined();
|
||||||
|
expect(g3!.textContent).toMatch(/G\s*3/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => {
|
it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => {
|
||||||
|
// The rail is viewport-independent (the #692 point); only the desktop
|
||||||
|
// stripe underlay is gated on the gutter breakpoint.
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||||
edges: [],
|
edges: [],
|
||||||
@@ -881,7 +886,6 @@ describe('StammbaumTree generation gutter (#689)', () => {
|
|||||||
showGutter: false
|
showGutter: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
|
await vi.waitFor(() => expect(railLabels()).toContain('Generation 3'));
|
||||||
expect(labelGroups).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ describe('serializePanZoomParams', () => {
|
|||||||
const state = { x: 87.5, y: -12.25, z: 2.4 };
|
const state = { x: 87.5, y: -12.25, z: 2.4 };
|
||||||
expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
|
expect(parsePanZoomParams(serializePanZoomParams(state))).toEqual(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rounds noisy floats so shared URLs stay readable', () => {
|
||||||
|
expect(serializePanZoomParams({ x: 457.8300882631206, y: 0, z: 1.2000000000000002 })).toEqual({
|
||||||
|
cx: '457.83',
|
||||||
|
cy: '0',
|
||||||
|
z: '1.2'
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('screenDeltaToSvg', () => {
|
describe('screenDeltaToSvg', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* of truth for the zoom bounds, the view-state shape, and every pure geometry
|
* of truth for the zoom bounds, the view-state shape, and every pure geometry
|
||||||
* helper used by the gesture action, the URL serialiser, and the page. Keeping
|
* helper used by the gesture action, the URL serialiser, and the page. Keeping
|
||||||
* the math here (and free of DOM access) makes it unit-testable in the node
|
* the math here (and free of DOM access) makes it unit-testable in the node
|
||||||
* project. See ADR-026 for why this is custom rather than a third-party library.
|
* project. See ADR-027 for why this is custom rather than a third-party library.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Resolved zoom bounds (OQ-001). */
|
/** Resolved zoom bounds (OQ-001). */
|
||||||
@@ -28,9 +28,17 @@ export const ZOOM_STEP_KB = 0.1;
|
|||||||
*/
|
*/
|
||||||
export type PanZoomState = { x: number; y: number; z: number };
|
export type PanZoomState = { x: number; y: number; z: number };
|
||||||
|
|
||||||
/** Fit-to-screen / initial view (US-PAN-004). */
|
/** Fit-to-screen target — frames the whole tree at z=1 (US-PAN-004). */
|
||||||
export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };
|
export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing zoom for a fresh visit (no URL state). Higher than fit so node tiles
|
||||||
|
* and generation labels are legible on arrival; the fit-to-screen control
|
||||||
|
* (DEFAULT_VIEW, z=1) zooms back out to the whole tree.
|
||||||
|
*/
|
||||||
|
export const INITIAL_ZOOM = 3;
|
||||||
|
export const INITIAL_VIEW: PanZoomState = { x: 0, y: 0, z: INITIAL_ZOOM };
|
||||||
|
|
||||||
/** Clamp a zoom factor into the supported range. */
|
/** Clamp a zoom factor into the supported range. */
|
||||||
export function clampZoom(z: number): number {
|
export function clampZoom(z: number): number {
|
||||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
||||||
@@ -63,9 +71,18 @@ export function parsePanZoomParams(raw: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialise a view state into URL query params (the inverse of {@link parsePanZoomParams}). */
|
/** Format a number with at most `dp` decimals, dropping trailing zeros. */
|
||||||
|
function round(n: number, dp: number): string {
|
||||||
|
return String(Number(n.toFixed(dp)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise a view state into URL query params (the inverse of
|
||||||
|
* {@link parsePanZoomParams}). Pan is rounded to 2 decimals and zoom to 3 so
|
||||||
|
* shared links stay readable (no `cx=457.8300882631206` float noise).
|
||||||
|
*/
|
||||||
export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } {
|
export function serializePanZoomParams(state: PanZoomState): { cx: string; cy: string; z: string } {
|
||||||
return { cx: String(state.x), cy: String(state.y), z: String(state.z) };
|
return { cx: round(state.x, 2), cy: round(state.y, 2), z: round(state.z, 3) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import { parsePanZoomParams } from '$lib/person/genealogy/panZoom';
|
import { parsePanZoomParams, INITIAL_VIEW } from '$lib/person/genealogy/panZoom';
|
||||||
|
|
||||||
export async function load({ fetch, url }) {
|
export async function load({ fetch, url }) {
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
@@ -13,14 +13,17 @@ export async function load({ fetch, url }) {
|
|||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitise the shareable pan/zoom params server-side so a crafted link
|
// A fresh visit (no shared pan/zoom state) lands at the readable INITIAL_VIEW
|
||||||
// (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout
|
// (z=3). When a link carries a zoom param we honour it, sanitising server-side
|
||||||
// geometry (Nora #692).
|
// so a crafted link (?z=Infinity, ?cx=NaN) degrades to a safe view before
|
||||||
const initialView = parsePanZoomParams({
|
// reaching layout geometry (Nora #692).
|
||||||
cx: url.searchParams.get('cx'),
|
const initialView = url.searchParams.has('z')
|
||||||
cy: url.searchParams.get('cy'),
|
? parsePanZoomParams({
|
||||||
z: url.searchParams.get('z')
|
cx: url.searchParams.get('cx'),
|
||||||
});
|
cy: url.searchParams.get('cy'),
|
||||||
|
z: url.searchParams.get('z')
|
||||||
|
})
|
||||||
|
: INITIAL_VIEW;
|
||||||
|
|
||||||
const network = result.data!;
|
const network = result.data!;
|
||||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
|
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack, tick } from 'svelte';
|
import { untrack, tick, onMount } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { replaceState } from '$app/navigation';
|
import { replaceState } from '$app/navigation';
|
||||||
@@ -62,18 +62,33 @@ function fitToScreen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` is a
|
// SvelteKit's replaceState throws "before the router is initialized" if called
|
||||||
// tracked dependency; the current URL is read untracked so the replaceState
|
// during hydration (the router sets `started = true` only after onMount + the
|
||||||
// write does not retrigger the effect. The state thus survives panel open/close
|
// first effect tick). Gate the URL sync on a flag flipped after the first
|
||||||
// (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
|
// post-mount tick() — which resolves once hydration is complete — so the write
|
||||||
|
// only ever runs against a ready router.
|
||||||
|
let routerReady = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
tick().then(() => (routerReady = true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror the view into shareable ?cx&cy&z params (OQ-003). Only `view` and
|
||||||
|
// `routerReady` are tracked; the current URL is read untracked so the
|
||||||
|
// replaceState write does not retrigger the effect. The state thus survives
|
||||||
|
// panel open/close (US-PANEL-002 AC1) and a shared link reproduces it (AC2).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { cx, cy, z } = serializePanZoomParams(view);
|
const { cx, cy, z } = serializePanZoomParams(view);
|
||||||
|
if (!routerReady) return;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('cx', cx);
|
url.searchParams.set('cx', cx);
|
||||||
url.searchParams.set('cy', cy);
|
url.searchParams.set('cy', cy);
|
||||||
url.searchParams.set('z', z);
|
url.searchParams.set('z', z);
|
||||||
replaceState(url, page.state);
|
try {
|
||||||
|
replaceState(url, page.state);
|
||||||
|
} catch {
|
||||||
|
// Router not ready yet — the next view change retries.
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ vi.mock('$lib/shared/api.server', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom';
|
import { DEFAULT_VIEW, INITIAL_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom';
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
@@ -26,11 +26,11 @@ function loadEvent(query = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('/stammbaum +page.server load — initialView', () => {
|
describe('/stammbaum +page.server load — initialView', () => {
|
||||||
it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => {
|
it('returns the readable INITIAL_VIEW (z=3) for a fresh visit with no params', async () => {
|
||||||
mockNetwork();
|
mockNetwork();
|
||||||
const { load } = await import('./+page.server');
|
const { load } = await import('./+page.server');
|
||||||
const result = await load(loadEvent() as never);
|
const result = await load(loadEvent() as never);
|
||||||
expect(result.initialView).toEqual(DEFAULT_VIEW);
|
expect(result.initialView).toEqual(INITIAL_VIEW);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses and returns valid ?cx&cy&z params', async () => {
|
it('parses and returns valid ?cx&cy&z params', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user