n:m attribute–evidence guide lines from card right to field left.
Overlay draws all links for the selected attribute or evidence; optional binder context preserves unit tests without BinderProvider.
This commit is contained in:
parent
32c5a2ede4
commit
9dc4880ec1
4 changed files with 105 additions and 83 deletions
|
|
@ -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. Attribute→card
|
||||
* when a Capture attribute is focused; card→highlight 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-pane→centre-pane→right-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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue