fix(stammbaum): keep dimmed nodes opaque so connectors do not bleed through (#703)
Group opacity on the node <g> made the whole node translucent — including its card fill — so the connector lines drawn beneath a dimmed node showed through it. Render the card fill at full strength outside the dim group and move the lineage focus+dim onto an inner content group (outline + labels) only. The focus ring also leaves the dim group, so a dimmed-but-focused node keeps a full-strength ring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
node: PersonNodeDTO;
|
node: PersonNodeDTO;
|
||||||
pos: { x: number; y: number };
|
pos: { x: number; y: number };
|
||||||
selected: boolean;
|
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;
|
dimmed?: boolean;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}
|
}
|
||||||
@@ -38,12 +38,11 @@ const datesLabel = $derived(
|
|||||||
aria-label="{node.displayName}{datesLabel}"
|
aria-label="{node.displayName}{datesLabel}"
|
||||||
aria-expanded={selected}
|
aria-expanded={selected}
|
||||||
transform="translate({pos.x}, {pos.y})"
|
transform="translate({pos.x}, {pos.y})"
|
||||||
opacity={dimmed ? DIMMED_OPACITY : undefined}
|
|
||||||
onclick={() => onSelect(node.id)}
|
onclick={() => onSelect(node.id)}
|
||||||
onkeydown={handleKey}
|
onkeydown={handleKey}
|
||||||
onfocus={() => (focused = true)}
|
onfocus={() => (focused = true)}
|
||||||
onblur={() => (focused = false)}
|
onblur={() => (focused = false)}
|
||||||
class="lineage-fade cursor-pointer focus:outline-none"
|
class="cursor-pointer focus:outline-none"
|
||||||
>
|
>
|
||||||
{#if focused}
|
{#if focused}
|
||||||
<rect
|
<rect
|
||||||
@@ -57,40 +56,52 @@ const datesLabel = $derived(
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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
|
<rect
|
||||||
width={NODE_W}
|
width={NODE_W}
|
||||||
height={NODE_H}
|
height={NODE_H}
|
||||||
rx="4"
|
rx="4"
|
||||||
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
/>
|
||||||
{#if selected}
|
<!-- Outline + labels carry the lineage focus+dim; the box stays opaque. -->
|
||||||
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
<g class="lineage-fade" opacity={dimmed ? DIMMED_OPACITY : undefined}>
|
||||||
{/if}
|
<rect
|
||||||
<text
|
width={NODE_W}
|
||||||
x={NODE_W / 2}
|
height={NODE_H}
|
||||||
y={NODE_H / 2 - 6}
|
rx="4"
|
||||||
text-anchor="middle"
|
fill="none"
|
||||||
font-family="serif"
|
stroke="var(--c-primary)"
|
||||||
font-size="16"
|
stroke-width="1.5"
|
||||||
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
/>
|
||||||
>
|
{#if selected}
|
||||||
{node.displayName}
|
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
||||||
</text>
|
{/if}
|
||||||
{#if node.birthYear || node.deathYear}
|
|
||||||
<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={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||||
opacity={selected ? 0.75 : 1}
|
|
||||||
>
|
>
|
||||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
{node.displayName}
|
||||||
</text>
|
</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>
|
</g>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -940,10 +940,27 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
edge('mutter', 'kind', 'PARENT_OF')
|
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 {
|
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}"]`);
|
const g = document.querySelector(`g[role="button"][aria-label="${displayName}"]`);
|
||||||
if (!g) throw new Error(`No node group rendered for ${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 = '0.4';
|
const DIM = '0.4';
|
||||||
|
|
||||||
@@ -974,8 +991,11 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
|
for (const name of ['Vater', 'Grossvater', 'Grossmutter', 'Kind', 'Mutter']) {
|
||||||
expect(nodeOpacity(name)).toBeNull();
|
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(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 () => {
|
it('recomputes the highlight for a newly selected person and clears the previous one (AC6)', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user