Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 75 additions & 26 deletions canvas/src/EntityNode.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "reactflow";
import type { Entity } from "./types";
import type { AttributeRow, Entity } from "./types";

export interface EntityNodeData {
entity: Entity;
color: string; // subject-area accent
dimmed: boolean; // search-filtered out
highlighted: boolean; // search hit / selected neighbourhood
showTypes: boolean;
/** attribute ULIDs that are an endpoint of some relationship (issue #5): pinned
* near the top so the anchored edges stay meaningful on tall cards */
endpointAttrs?: Set<string>;
/** rows to emphasise / mute while hovering an attribute or an edge (issue #5) */
highlightAttrs?: Set<string>;
dimAttrs?: Set<string>;
/** hover an attribute row -> the canvas highlights the relationships it joins */
onHoverAttr?: (attrId: string | null) => void;
}

const ROLE_ICON: Record<string, string> = {
Expand All @@ -21,10 +29,67 @@ function typeLabel(domain: string | null): string {
return domain ?? "";
}

/** Stable per-attribute handle ids. Both a source and a target handle share the
* attribute's ULID — React Flow requires uniqueness only PER TYPE, and an edge
* disambiguates via sourceHandle vs targetHandle. The entity-level fallback
* handles use reserved ids that can never collide with a ULID. */
export const ENTITY_SOURCE_HANDLE = "__entity_source";
export const ENTITY_TARGET_HANDLE = "__entity_target";

export const EntityNode = memo(function EntityNode({ data, selected }: NodeProps<EntityNodeData>) {
const { entity, color, dimmed, highlighted, showTypes } = data;
const keys = entity.attributes.filter((a) => a.role === "business_key");
const rest = entity.attributes.filter((a) => a.role !== "business_key");
const {
entity,
color,
dimmed,
highlighted,
showTypes,
endpointAttrs,
highlightAttrs,
dimAttrs,
onHoverAttr,
} = data;

// business keys first (unchanged), then FK/relationship endpoints, then the rest —
// so the attributes edges anchor to stay visible near the header (issue #5 pinning).
const rank = (a: AttributeRow): number => {
if (a.role === "business_key") return 0;
if (endpointAttrs?.has(a.id)) return 1;
return 2;
};
const ordered = [...entity.attributes]
.map((a, i) => ({ a, i }))
.sort((x, y) => rank(x.a) - rank(y.a) || x.i - y.i) // stable within a rank
.map((x) => x.a);
const keys = ordered.filter((a) => a.role === "business_key");
const rest = ordered.filter((a) => a.role !== "business_key");

const row = (a: AttributeRow, pk: boolean) => {
const cls =
"attr-row" +
(highlightAttrs?.has(a.id) ? " rel-hit" : "") +
(dimAttrs?.has(a.id) ? " rel-dim" : "") +
(endpointAttrs?.has(a.id) ? " endpoint" : "");
return (
<div
key={a.id}
className={cls}
onMouseEnter={onHoverAttr ? () => onHoverAttr(a.id) : undefined}
onMouseLeave={onHoverAttr ? () => onHoverAttr(null) : undefined}
>
{/* per-row connection points — anchor edges to the concrete column */}
<Handle id={a.id} type="target" position={Position.Left} className="port attr-port" />
<Handle id={a.id} type="source" position={Position.Right} className="port attr-port" />
<span className="attr-icon">{ROLE_ICON[a.role]}</span>
<span className={"attr-name" + (pk ? " pk" : "")}>{a.name}</span>
{showTypes && <span className="attr-type">{typeLabel(a.domain)}</span>}
{!a.nullable && (
<span className="attr-notnull" title="not null">
{"●"}
</span>
)}
</div>
);
};

return (
<div
Expand All @@ -36,8 +101,10 @@ export const EntityNode = memo(function EntityNode({ data, selected }: NodeProps
}
style={{ borderTopColor: color }}
>
<Handle type="target" position={Position.Left} className="port" />
<Handle type="source" position={Position.Right} className="port" />
{/* Entity-level fallback handles for relationships with no attribute mapping.
Explicit ids so an edge can target them and they never clash with a row. */}
<Handle id={ENTITY_TARGET_HANDLE} type="target" position={Position.Left} className="port" />
<Handle id={ENTITY_SOURCE_HANDLE} type="source" position={Position.Right} className="port" />

<div className="entity-header">
<span className="entity-name" title={entity.conceptual?.definition ?? undefined}>
Expand All @@ -59,28 +126,10 @@ export const EntityNode = memo(function EntityNode({ data, selected }: NodeProps
</span>
</div>

{keys.length > 0 && (
<div className="attr-section keys">
{keys.map((a) => (
<div key={a.id} className="attr-row">
<span className="attr-icon">{ROLE_ICON[a.role]}</span>
<span className="attr-name pk">{a.name}</span>
{showTypes && <span className="attr-type">{typeLabel(a.domain)}</span>}
{!a.nullable && <span className="attr-notnull" title="not null">{"●"}</span>}
</div>
))}
</div>
)}
{keys.length > 0 && <div className="attr-section keys">{keys.map((a) => row(a, true))}</div>}

<div className="attr-section">
{rest.map((a) => (
<div key={a.id} className="attr-row">
<span className="attr-icon">{ROLE_ICON[a.role]}</span>
<span className="attr-name">{a.name}</span>
{showTypes && <span className="attr-type">{typeLabel(a.domain)}</span>}
{!a.nullable && <span className="attr-notnull" title="not null">{"●"}</span>}
</div>
))}
{rest.map((a) => row(a, false))}
{entity.attributes.length === 0 && <div className="attr-row empty">no attributes</div>}
</div>
</div>
Expand Down
181 changes: 152 additions & 29 deletions canvas/src/ModelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import ReactFlow, {
// whichever shell mounts it to import this is how the modeler app ended up with an
// unpositioned minimap sprawled across the canvas.
import "reactflow/dist/style.css";
import { EntityNode, type EntityNodeData } from "./EntityNode";
import {
ENTITY_SOURCE_HANDLE,
ENTITY_TARGET_HANDLE,
EntityNode,
type EntityNodeData,
} from "./EntityNode";
import type { Capabilities, Exec } from "./exec";
import { Inspector } from "./Inspector";
import { layoutGraph } from "./layout";
Expand All @@ -31,6 +36,27 @@ const edgeTypes: EdgeTypes = { relationship: RelationshipEdge };
export const PALETTE = ["#e0a63a", "#e8825a", "#d98ba8", "#c4a3e0", "#7fb0d8", "#7fc9a0", "#e6c14a"];
export const NO_SA_COLOR = "#8a7d6a";

/** Which React Flow handle each end of a relationship anchors to (issue #5).
*
* Per end: if the relationship maps to an attribute that actually exists on that
* entity, anchor the line to that attribute's row handle; otherwise fall back to the
* entity-level header handle, so an unmapped relationship still renders exactly as it
* did before. Composite keys anchor to the FIRST member and carry a member count on
* the edge (one line, IDEF1X-style, not N overlapping crow's-feet). */
export function anchorFor(
r: { from: { entity: string; attributes: string[] }; to: { entity: string; attributes: string[] } },
entityIndex: Map<string, Entity>,
): { sourceHandle: string; targetHandle: string } {
const resolves = (entityId: string, attrId: string | undefined): boolean =>
!!attrId && !!entityIndex.get(entityId)?.attributes.some((a) => a.id === attrId);
const fromAttr = r.from.attributes[0];
const toAttr = r.to.attributes[0];
return {
sourceHandle: resolves(r.from.entity, fromAttr) ? fromAttr : ENTITY_SOURCE_HANDLE,
targetHandle: resolves(r.to.entity, toAttr) ? toAttr : ENTITY_TARGET_HANDLE,
};
}

/** Imperative handle for the things a shell needs to drive from its own chrome
* (the toolbar's re-layout and fit buttons, and focus-by-id from search). */
export interface ModelCanvasHandle {
Expand Down Expand Up @@ -84,8 +110,17 @@ export function ModelCanvas({
const [alignFor, setAlignFor] = useState<Entity | null>(null);
const [mapFor, setMapFor] = useState<Entity | null>(null);
const [newEntityOpen, setNewEntityOpen] = useState(false);
const [relDraft, setRelDraft] = useState<{ from: string; to: string } | null>(null);
const [relDraft, setRelDraft] = useState<{
from: string;
to: string;
fromAttr?: string;
toAttr?: string;
} | null>(null);
const [relEdit, setRelEdit] = useState<string | null>(null);
// hover state for attribute-level relationship highlighting (issue #5). Transient
// and canvas-local; it feeds the existing dimmed/highlighted computation.
const [hoveredAttr, setHoveredAttr] = useState<string | null>(null);
const [hoveredEdge, setHoveredEdge] = useState<string | null>(null);

const readOnly = !caps.canEdit;

Expand All @@ -101,6 +136,23 @@ export function ModelCanvas({
return m;
}, [doc]);

// entity id -> the set of its attribute ULIDs that are an endpoint of some
// relationship, for pinning them near the header (issue #5). Built once per model.
const endpointAttrsByEntity = useMemo(() => {
const m = new Map<string, Set<string>>();
const add = (entity: string, attrs: string[]) => {
if (!attrs.length) return;
const s = m.get(entity) ?? new Set<string>();
attrs.forEach((a) => s.add(a));
m.set(entity, s);
};
for (const r of doc.relationships) {
add(r.from.entity, r.from.attributes);
add(r.to.entity, r.to.attributes);
}
return m;
}, [doc]);

useEffect(() => {
const q = query.trim().toLowerCase();
const matches = new Set<string>();
Expand All @@ -124,34 +176,80 @@ export function ModelCanvas({
}
}

const newNodes: Node<EntityNodeData>[] = doc.entities.map((e) => ({
id: e.id,
type: "entity",
position: { x: 0, y: 0 },
data: {
entity: e,
color: e.conceptual?.subject_area
? saColors.get(e.conceptual.subject_area.id) ?? NO_SA_COLOR
: NO_SA_COLOR,
dimmed: (q !== "" && !matches.has(e.id)) || (selectedId !== null && !neighbours.has(e.id)),
highlighted: q !== "" && matches.has(e.id),
showTypes,
},
}));
// --- attribute-level hover (issue #5) -------------------------------------
// Which relationships does the hovered attribute participate in, and which
// attribute rows does the hovered edge connect? Both feed the dimmed/highlighted
// flags below rather than a parallel highlight system.
const relsForAttr = new Set<string>(); // relationship ids touching hoveredAttr
const entsForAttr = new Set<string>(); // entities on those relationships
if (hoveredAttr) {
for (const r of doc.relationships) {
if (r.from.attributes.includes(hoveredAttr) || r.to.attributes.includes(hoveredAttr)) {
relsForAttr.add(r.id);
entsForAttr.add(r.from.entity);
entsForAttr.add(r.to.entity);
}
}
}
// attribute rows to emphasise, keyed by entity, when an edge is hovered
const hitAttrsByEntity = new Map<string, Set<string>>();
if (hoveredEdge) {
const r = doc.relationships.find((x) => x.id === hoveredEdge);
if (r) {
hitAttrsByEntity.set(r.from.entity, new Set(r.from.attributes));
hitAttrsByEntity.set(r.to.entity, new Set(r.to.attributes));
}
}

const newNodes: Node<EntityNodeData>[] = doc.entities.map((e) => {
const searchDim = q !== "" && !matches.has(e.id);
const neighbourDim = selectedId !== null && !neighbours.has(e.id);
// when hovering an attribute, dim entities not on any of its relationships
const hoverDim = hoveredAttr !== null && !entsForAttr.has(e.id);
return {
id: e.id,
type: "entity",
position: { x: 0, y: 0 },
data: {
entity: e,
color: e.conceptual?.subject_area
? saColors.get(e.conceptual.subject_area.id) ?? NO_SA_COLOR
: NO_SA_COLOR,
dimmed: searchDim || neighbourDim || hoverDim,
highlighted:
(q !== "" && matches.has(e.id)) || (hoveredAttr !== null && entsForAttr.has(e.id)),
showTypes,
endpointAttrs: endpointAttrsByEntity.get(e.id),
highlightAttrs: hitAttrsByEntity.get(e.id),
onHoverAttr: setHoveredAttr,
},
};
});

const newEdges: Edge<RelationshipEdgeData>[] = doc.relationships
.filter((r) => entityIndex.has(r.from.entity) && entityIndex.has(r.to.entity))
.map((r) => ({
id: r.id,
source: r.from.entity,
target: r.to.entity,
type: "relationship",
data: {
relationship: r,
dimmed:
selectedId !== null && r.from.entity !== selectedId && r.to.entity !== selectedId,
},
}));
.map((r) => {
const { sourceHandle, targetHandle } = anchorFor(r, entityIndex);
// dim: search/select context, OR (when hovering an attribute) any edge that
// attribute is not part of.
const selectDim =
selectedId !== null && r.from.entity !== selectedId && r.to.entity !== selectedId;
const hoverDim = hoveredAttr !== null && !relsForAttr.has(r.id);
return {
id: r.id,
source: r.from.entity,
target: r.to.entity,
sourceHandle,
targetHandle,
type: "relationship",
data: {
relationship: r,
dimmed: selectDim || hoverDim,
memberCount: Math.max(r.from.attributes.length, r.to.attributes.length),
onHoverEdge: setHoveredEdge,
},
};
});

setNodes((prev) => {
const posById = new Map(prev.map((n) => [n.id, n.position]));
Expand All @@ -169,7 +267,18 @@ export function ModelCanvas({
return merged;
});
setEdges(newEdges);
}, [doc, query, selectedId, showTypes, saColors, entityIndex, fitView]);
}, [
doc,
query,
selectedId,
showTypes,
saColors,
entityIndex,
endpointAttrsByEntity,
hoveredAttr,
hoveredEdge,
fitView,
]);

const relayout = useCallback(() => {
setNodes((prev) => layoutGraph(prev, edges, entityIndex));
Expand Down Expand Up @@ -202,7 +311,19 @@ export function ModelCanvas({
const onConnect = useCallback(
(conn: Connection) => {
if (readOnly || !conn.source || !conn.target || conn.source === conn.target) return;
setRelDraft({ from: conn.source, to: conn.target });
// A drag from one column to another (issue #5) carries the attribute ULIDs as
// the handle ids; the entity-level fallback handles are reserved sentinels.
// Prefill the relationship modal with them so drawing column→column proposes
// the FK directly, while keeping the confirm step (cardinality/optionality).
const fromAttr =
conn.sourceHandle && conn.sourceHandle !== ENTITY_SOURCE_HANDLE
? conn.sourceHandle
: undefined;
const toAttr =
conn.targetHandle && conn.targetHandle !== ENTITY_TARGET_HANDLE
? conn.targetHandle
: undefined;
setRelDraft({ from: conn.source, to: conn.target, fromAttr, toAttr });
},
[readOnly],
);
Expand Down Expand Up @@ -294,6 +415,8 @@ export function ModelCanvas({
doc={doc}
fromId={relDraft.from}
toId={relDraft.to}
initialFromAttr={relDraft.fromAttr}
initialToAttr={relDraft.toAttr}
exec={guardedExec}
onClose={() => setRelDraft(null)}
/>
Expand Down
Loading
Loading