n:m attribute–evidence guide lines from card right to field left.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Overlay draws all links for the selected attribute or evidence; optional
binder context preserves unit tests without BinderProvider.
This commit is contained in:
tegwick 2026-07-30 20:31:49 +02:00
parent 32c5a2ede4
commit 9dc4880ec1
4 changed files with 105 additions and 83 deletions

View file

@ -1,28 +1,21 @@
/**
* Visual-guide overlay draws curves between the active triple.
* Visual-guide overlay draws curves between evidence and attributes.
*
* Subscribes to the rect registry + active-state machine and redraws a
* pair of bezier curves on every rect-change event:
* Geometry (Capture layout: Evidence column left of Attributes):
* evidence-card right edge attribute left edge
*
* field evidence-card highlight
* Relation is n:m:
* - Active attribute lines to every linked evidence card
* - Active evidence (no attribute focus) lines to every linked attribute
* - Active evidence also card highlight in the viewer
*
* Throttling: `attachRectChangePumps` already coalesces scroll/resize
* bursts into one `rect-changed` per animation frame. The overlay's
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
* up that single tick and React re-renders once per frame.
*
* Active-only: draws legs for the active evidence card. Attributecard
* when a Capture attribute is focused; cardhighlight whenever the
* highlight rect is available (Annotate or Capture). Missing legs are
* omitted independently.
*
* MVP-sufficient. Future polish: easing the curve direction by source
* type, animating the transition between active states, dimming
* non-active rects rather than hiding them.
* Throttling: `attachRectChangePumps` coalesces scroll/resize into one
* `rect-changed` per animation frame; `useRectRegistryVersion` re-renders.
*/
import { useMemo } from "react";
import { useBinderOptional } from "../BinderProvider";
import { useActiveState } from "../state/active";
import {
useRectRegistryContext,
@ -33,11 +26,16 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function rectRightCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.right, y: rect.top + rect.height / 2 };
}
function rectLeftCenter(rect: DOMRect): { x: number; y: number } {
return { x: rect.left, y: rect.top + rect.height / 2 };
}
/**
* Build a quadratic bezier from `a` to `b` whose control point bulges
* horizontally between them. The horizontal-bulge style is right for a
* left-panecentre-paneright-pane layout; vertical-bulge can be added
* later when we have a layout that needs it.
* Quadratic bezier with horizontal bulge suited to side-by-side columns.
*/
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
const dx = b.x - a.x;
@ -46,11 +44,8 @@ function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): s
}
export interface OverlayProps {
/** Curve stroke colour. Defaults to the engine's accent blue. */
readonly strokeColor?: string;
/** Curve stroke width. Defaults to 2px. */
readonly strokeWidth?: number;
/** Optional className for styling hooks; the inline styles cover layout. */
readonly className?: string;
}
@ -60,32 +55,52 @@ export function Overlay({
className,
}: OverlayProps = {}) {
const { state } = useActiveState();
const binder = useBinderOptional();
const { registry } = useRectRegistryContext();
const version = useRectRegistryVersion();
const paths = useMemo<readonly string[]>(() => {
// CE-WP-0010: card → highlight is enough for Annotate mode (no attribute
// target). Field → card still draws when Capture has an active target.
if (!state.activeEvidenceItemId) return [];
const fieldRect = state.activeTarget
? registry.getRect("field", state.activeTarget.targetId)
: null;
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
const highlightRect = state.activeAnnotationId
? registry.getRect("highlight", state.activeAnnotationId)
: null;
const out: string[] = [];
if (fieldRect && cardRect) {
// Horizontal layout (Evidence | Attributes): centre-to-centre reads better
// than the old bottom-strip geometry.
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
const bindings = binder?.bindings;
const pushCardField = (evidenceItemId: string, fieldId: string) => {
const cardRect = registry.getRect("evidence-card", evidenceItemId);
const fieldRect = registry.getRect("field", fieldId);
if (!cardRect || !fieldRect) return;
// Evidence (left) right edge → attribute (right) left edge
out.push(bezierPath(rectRightCenter(cardRect), rectLeftCenter(fieldRect)));
};
if (bindings && state.activeTarget?.targetType === "form-field") {
// Attribute selected: all linked evidence cards
const links = bindings.listEvidenceForTarget(state.activeTarget);
for (const link of links) {
pushCardField(link.evidenceItemId, state.activeTarget.targetId);
}
} else if (bindings && state.activeEvidenceItemId) {
// Evidence selected: all linked attributes
const links = bindings.listTargetsForEvidence(state.activeEvidenceItemId);
for (const link of links) {
if (link.targetType !== "form-field") continue;
pushCardField(state.activeEvidenceItemId, link.targetId);
}
} else if (state.activeTarget && state.activeEvidenceItemId) {
// Fallback without binder (unit tests): single active pair
pushCardField(state.activeEvidenceItemId, state.activeTarget.targetId);
}
if (cardRect && highlightRect) {
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
// Evidence → citation highlight (Annotate + Capture)
if (state.activeEvidenceItemId && state.activeAnnotationId) {
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
const highlightRect = registry.getRect("highlight", state.activeAnnotationId);
if (cardRect && highlightRect) {
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
}
}
void version; // memo invalidator
void version;
return out;
}, [state, registry, version]);
}, [state, registry, version, binder]);
if (paths.length === 0) return null;