Compare commits
5 Commits
e5784caa9d
...
81224829a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81224829a2 | ||
|
|
7cc2ddc6ad | ||
|
|
da3067150d | ||
|
|
10249c33be | ||
|
|
9c12f62345 |
@@ -106,8 +106,11 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
{@const aCenter = nodeCenter(group.parentA)}
|
||||
{@const bCenter = nodeCenter(group.parentB)}
|
||||
{@const childCenters = group.childIds
|
||||
.map((id) => nodeCenter(id))
|
||||
.filter((c): c is { x: number; y: number } => c !== null)}
|
||||
.map((id) => {
|
||||
const c = nodeCenter(id);
|
||||
return c ? { id, x: c.x, y: c.y } : null;
|
||||
})
|
||||
.filter((c): c is { id: string; x: number; y: number } => c !== null)}
|
||||
{#if aCenter && bCenter && childCenters.length > 0}
|
||||
{@const midX = (aCenter.x + bCenter.x) / 2}
|
||||
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
||||
@@ -138,13 +141,14 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
/>
|
||||
{/if}
|
||||
</g>
|
||||
{#each childCenters as cc, i (group.childIds[i])}
|
||||
{#each childCenters as cc (cc.id)}
|
||||
<!-- Each vertical joins the parent pair to one child: active only when
|
||||
both parents and that child are active, so the same bar can stay lit
|
||||
to the lineage child while dimming to a collateral sibling. -->
|
||||
to the lineage child while dimming to a collateral sibling. The child
|
||||
id rides on the centre object, so it never desyncs from the filtered
|
||||
centres (a child without a position drops out of both together). -->
|
||||
{@const childActive =
|
||||
isConnectorActive(group.parentA, group.childIds[i]) &&
|
||||
isConnectorActive(group.parentB, group.childIds[i])}
|
||||
isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)}
|
||||
<g class="lineage-fade" opacity={connectorOpacity(childActive)}>
|
||||
<line
|
||||
x1={cc.x}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
node: PersonNodeDTO;
|
||||
pos: { x: number; y: number };
|
||||
selected: boolean;
|
||||
/** Dim the whole node when a lineage is highlighted and this person is outside it. */
|
||||
/** Dim the node's outline + labels when a lineage is highlighted and this person is outside it. */
|
||||
dimmed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
@@ -38,12 +38,11 @@ const datesLabel = $derived(
|
||||
aria-label="{node.displayName}{datesLabel}"
|
||||
aria-expanded={selected}
|
||||
transform="translate({pos.x}, {pos.y})"
|
||||
opacity={dimmed ? DIMMED_OPACITY : undefined}
|
||||
onclick={() => onSelect(node.id)}
|
||||
onkeydown={handleKey}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={() => (focused = false)}
|
||||
class="lineage-fade cursor-pointer focus:outline-none"
|
||||
class="cursor-pointer focus:outline-none"
|
||||
>
|
||||
{#if focused}
|
||||
<rect
|
||||
@@ -57,40 +56,52 @@ const datesLabel = $derived(
|
||||
stroke-width="2"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Opaque card fill — full strength even when dimmed, so the connectors
|
||||
drawn beneath the node never bleed through. The lineage dim lives on the
|
||||
content group below; group opacity here would make the fill translucent. -->
|
||||
<rect
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx="4"
|
||||
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{#if selected}
|
||||
<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={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||
>
|
||||
{node.displayName}
|
||||
</text>
|
||||
{#if node.birthYear || node.deathYear}
|
||||
<!-- Outline + labels carry the lineage focus+dim; the box stays opaque. -->
|
||||
<g class="lineage-fade" opacity={dimmed ? DIMMED_OPACITY : undefined}>
|
||||
<rect
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx="4"
|
||||
fill="none"
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{#if selected}
|
||||
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
||||
{/if}
|
||||
<text
|
||||
x={NODE_W / 2}
|
||||
y={NODE_H / 2 + 12}
|
||||
y={NODE_H / 2 - 6}
|
||||
text-anchor="middle"
|
||||
font-family="sans-serif"
|
||||
font-size="12"
|
||||
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||||
opacity={selected ? 0.75 : 1}
|
||||
font-family="serif"
|
||||
font-size="16"
|
||||
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||
>
|
||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||
{node.displayName}
|
||||
</text>
|
||||
{/if}
|
||||
{#if node.birthYear || node.deathYear}
|
||||
<text
|
||||
x={NODE_W / 2}
|
||||
y={NODE_H / 2 + 12}
|
||||
text-anchor="middle"
|
||||
font-family="sans-serif"
|
||||
font-size="12"
|
||||
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||||
opacity={selected ? 0.75 : 1}
|
||||
>
|
||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import StammbaumTree from './StammbaumTree.svelte';
|
||||
import type { PanZoomState } from './panZoom';
|
||||
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
||||
|
||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||
@@ -940,12 +941,38 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
||||
edge('mutter', 'kind', 'PARENT_OF')
|
||||
];
|
||||
|
||||
// The lineage dim lives on the inner content group (outline + labels); the
|
||||
// card fill renders outside it at full strength so connectors beneath never
|
||||
// bleed through a dimmed node.
|
||||
function nodeOpacity(displayName: string): string | null {
|
||||
const content = nodeContentGroup(displayName);
|
||||
return content.getAttribute('opacity');
|
||||
}
|
||||
function nodeContentGroup(displayName: string): Element {
|
||||
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
|
||||
if (!g) throw new Error(`No node group rendered for ${displayName}`);
|
||||
return g.getAttribute('opacity');
|
||||
const content = g.querySelector('g.lineage-fade');
|
||||
if (!content) throw new Error(`No content group rendered for ${displayName}`);
|
||||
return content;
|
||||
}
|
||||
/** The opaque card fill is a direct-child rect of the node, outside the dim group. */
|
||||
function cardFillIsOpaque(displayName: string): boolean {
|
||||
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
|
||||
if (!g) throw new Error(`No node group rendered for ${displayName}`);
|
||||
const fill = g.querySelector(':scope > rect');
|
||||
if (!fill) throw new Error(`No card-fill rect rendered for ${displayName}`);
|
||||
return fill.getAttribute('opacity') === null && fill.getAttribute('fill-opacity') === null;
|
||||
}
|
||||
const DIM = String(DIMMED_OPACITY);
|
||||
|
||||
// Connector groups render as direct <svg> children; the node content groups
|
||||
// (also .lineage-fade) are nested inside g[role="button"], so the child
|
||||
// combinator scopes cleanly to connectors.
|
||||
function dimmedConnectorCount(): number {
|
||||
return Array.from(document.querySelectorAll('svg > g.lineage-fade')).filter(
|
||||
(g) => g.getAttribute('opacity') === DIM
|
||||
).length;
|
||||
}
|
||||
const DIM = '0.4';
|
||||
|
||||
it('renders every node at full strength when nothing is selected (AC1)', () => {
|
||||
render(StammbaumTree, {
|
||||
@@ -974,8 +1001,11 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
||||
for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
|
||||
expect(nodeOpacity(name)).toBeNull();
|
||||
}
|
||||
// The collateral sibling dims.
|
||||
// The collateral sibling dims — but its card fill stays opaque so the
|
||||
// connectors drawn beneath it do not show through (the dim is on the
|
||||
// outline + labels only).
|
||||
expect(nodeOpacity('Tante')).toBe(DIM);
|
||||
expect(cardFillIsOpaque('Tante')).toBe(true);
|
||||
});
|
||||
|
||||
it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => {
|
||||
@@ -1026,4 +1056,33 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
||||
});
|
||||
for (const n of NODES) expect(nodeOpacity(n.displayName)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves every connector at full strength when nothing is selected (AC5)', () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: NODES,
|
||||
edges: EDGES,
|
||||
selectedId: null,
|
||||
panZoom: { x: 0, y: 0, z: 1 },
|
||||
showGutter: false,
|
||||
onSelect: () => {}
|
||||
});
|
||||
expect(dimmedConnectorCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('dims exactly the connector feeding the collateral child at the render layer (AC5)', () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: NODES,
|
||||
edges: EDGES,
|
||||
selectedId: 'vater',
|
||||
panZoom: { x: 0, y: 0, z: 1 },
|
||||
showGutter: false,
|
||||
onSelect: () => {}
|
||||
});
|
||||
// Every connector among the bloodline + spouses stays full strength; only
|
||||
// the vertical joining the active parent pair (Grossvater+Grossmutter) to
|
||||
// the dimmed collateral child (Tante) renders at DIMMED_OPACITY. This proves
|
||||
// the <g opacity> render wiring — not just the isConnectorActive predicate —
|
||||
// and exercises the shared parent-pair per-child path.
|
||||
expect(dimmedConnectorCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,13 @@ import type { components } from '$lib/generated/api';
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
/**
|
||||
* Opacity applied to dimmed nodes and connectors. ~0.4 keeps names legible while
|
||||
* clearly de-emphasised, and works as a lightness cue in both themes (the colour
|
||||
* tokens are theme-aware) so the cue does not rely on hue (WCAG 1.4.1 / NFR-A11Y-001).
|
||||
* Opacity applied to dimmed node outlines/labels and connectors. 0.45 keeps names
|
||||
* legible against bg-surface in both themes (dark mode dims already-light mint, the
|
||||
* riskier case) while clearly de-emphasised, and works as a lightness cue so the cue
|
||||
* does not rely on hue (WCAG 1.4.1 / NFR-A11Y-001). The dim is applied to the node's
|
||||
* outline + labels only — the card fill stays opaque, see StammbaumNode.svelte.
|
||||
*/
|
||||
export const DIMMED_OPACITY = 0.4;
|
||||
export const DIMMED_OPACITY = 0.45;
|
||||
|
||||
/** Adjacency index over the family graph, built once per edge set. */
|
||||
export type LineageIndex = {
|
||||
|
||||
@@ -24,7 +24,28 @@ vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn()
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
// The page reads window.matchMedia('(max-width: 767px)').matches at init to
|
||||
// decide whether to centre a tapped person above the bottom sheet (#703 AC8).
|
||||
// Make the mock query-aware so only the mobile breakpoint flips; other media
|
||||
// queries (e.g. prefers-reduced-motion) keep their benign default.
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
function mockMatchMedia(isMobile: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes('max-width') ? isMobile : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
async function loadComponent() {
|
||||
return (await import('./+page.svelte')).default;
|
||||
@@ -35,6 +56,12 @@ const sampleNodes = [
|
||||
{ id: 'p-2', firstName: 'Bert', lastName: 'Schmidt', displayName: 'Bert Schmidt' }
|
||||
];
|
||||
|
||||
// Typed family nodes for the AC8 tests — familyMember is required on the DTO.
|
||||
const familyNodes = [
|
||||
{ id: 'p-1', displayName: 'Anna Schmidt', familyMember: true },
|
||||
{ id: 'p-2', displayName: 'Bert Schmidt', familyMember: true }
|
||||
];
|
||||
|
||||
describe('stammbaum page', () => {
|
||||
it('shows the empty state when there are no family nodes', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
@@ -125,4 +152,44 @@ describe('stammbaum page', () => {
|
||||
expect(url.searchParams.has('cx')).toBe(true);
|
||||
expect(url.searchParams.has('cy')).toBe(true);
|
||||
});
|
||||
|
||||
// AC8 — the tapped person must clear the bottom sheet on a phone, but the
|
||||
// desktop side panel is a flex sibling that never overlaps the canvas, so no
|
||||
// centring should fire there. These tests prove the matchMedia gate around
|
||||
// selectPerson, not just the recentreAbove geometry (covered in panZoom.test).
|
||||
it('recentres the tapped person when matchMedia reports mobile (#703 AC8)', async () => {
|
||||
mockMatchMedia(true);
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
// Let the mount-time URL mirror settle, then isolate the tap's effect.
|
||||
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
|
||||
replaceState.mockClear();
|
||||
|
||||
await page.getByRole('button', { name: 'Anna Schmidt' }).click();
|
||||
|
||||
// The mobile tap recentres the canvas → the view changes → the ?cx&cy&z
|
||||
// mirror effect re-fires. (Desktop, below, leaves the view untouched.)
|
||||
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('does not recentre on tap when matchMedia reports desktop (#703 AC8)', async () => {
|
||||
mockMatchMedia(false);
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
|
||||
replaceState.mockClear();
|
||||
|
||||
await page.getByRole('button', { name: 'Anna Schmidt' }).click();
|
||||
|
||||
// The tap registers — the desktop side panel opens — but no recentre fires,
|
||||
// so the view never changes and the mirror effect stays silent.
|
||||
await expect.element(page.getByRole('complementary')).toBeVisible();
|
||||
expect(replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user