graph-to-flow.ts7.4 KBView on GitHub /**
* Domain → React Flow, and back.
*
* ── The layout pass runs over ONE edge kind ──
*
* Only the edges whose `kind === view.layoutEdgeKind` participate in `autoLayout`. Which
* relation is the skeleton is therefore a VIEW property: point it at `influences` instead of
* `reports_to` and the same nodes re-rank, with nothing written. The non-layout edges draw as
* overlays afterwards and never affect a position — adding a `worked_with` edge must not move
* anybody.
*
* ── A node absent from `graphLayout` takes the computed position ──
*
* …and the caller persists it ONCE, so the next open is stable and a subsequent re-layout is a
* deliberate act rather than something that happens because someone opened the file. `toPersist`
* is that list; it is empty on every open after the first.
*/
import { hierarchyLayout } from './hierarchyLayout';
import {
GRAPH_EDGE_TYPE,
GRAPH_NODE_TYPE,
type GraphFlowEdge,
type GraphFlowNode,
type GraphNodeData,
type GraphNodeLint,
} from './graph-flow-types';
import type {
GraphEdge,
GraphLayout,
GraphNode,
GraphPosition,
GraphSchema,
} from '@zero/server/graph';
export interface GraphToFlowInput {
schema: GraphSchema;
nodes: Record<string, GraphNode>;
edges: Record<string, GraphEdge>;
layout: GraphLayout;
/** Bound-field values from `hydrateNodes`, keyed by node id. Absent until they arrive. */
hydrated?: Record<string, Record<string, unknown>>;
/** Node ids whose pointed-at document no longer resolves. */
missingDocumentIds?: ReadonlySet<string>;
/** Lint findings, keyed by node id, drawn as a ring rather than as a panel. */
lintByNode?: Record<string, GraphNodeLint[]>;
}
export interface GraphFlow {
nodes: GraphFlowNode[];
edges: GraphFlowEdge[];
/**
* Positions that were COMPUTED rather than read — the caller persists these once so the
* layout stops being recomputed on every open.
*/
toPersist: Record<string, GraphPosition>;
}
/**
* The reporting tree on top, everyone else tiered below it.
*
* ONE function for both the automatic pass and the `Auto-format` button, so what you get on
* open and what you get on click can never diverge — they did, and the button looked broken
* because it discarded the reporting structure the automatic pass had just drawn.
*/
export function computeGraphLayout(
input: Pick<GraphToFlowInput, 'schema' | 'nodes'>,
layoutEdges: readonly GraphEdge[],
): Record<string, GraphPosition> {
const tierKey=[redacted];
const tierField = tierKey
? input.schema.nodeSchema.fields.find((f) => f.key === tierKey)
: undefined;
return hierarchyLayout({
nodes: Object.values(input.nodes),
layoutEdges,
...(tierField ? { tierField } : {}),
labelOf: (id) => input.nodes[id]?.title ?? id,
rankdir: input.schema.view?.mode === 'dagre-lr' ? 'LR' : 'TB',
});
}
/**
* The colour a node is painted, from `view.colorField` and that field's `optionColors`.
*
* Returns the NAME, not a class — the name is a closed shared vocabulary the renderer maps, so
* an unrecognised one yields no tint rather than a broken one.
*/
export function nodeColorOf(
schema: GraphSchema,
node: Pick<GraphNode, 'fields' | 'derived'>,
): string | undefined {
const key=[redacted];
if (!key) return undefined;
const field = schema.nodeSchema.fields.find((f) => f.key === key);
if (!field?.optionColors) return undefined;
const raw = field.from ? node.derived?.[key] : node.fields[key];
// A multi_select paints from its FIRST value: a node has one background, and blending two
// would produce a third colour that means nothing in the legend.
const value = Array.isArray(raw) ? raw[0] : raw;
if (value === undefined || value === null || value === '') return undefined;
return field.optionColors[String(value)];
}
export function graphToFlow(input: GraphToFlowInput): GraphFlow {
const entries = Object.entries(input.nodes);
const hidden = new Set(input.schema.view?.hiddenEdgeKinds ?? []);
// Hidden kinds are dropped from the DRAWING only. They never hide a node, and they still
// participate in the layout if one of them is the axis — hiding the relation you are ranked by
// would silently rearrange the canvas.
const allEdges = Object.values(input.edges).filter(
(edge) => !!input.nodes[edge.source] && !!input.nodes[edge.target],
);
const layoutKind = input.schema.view?.layoutEdgeKind;
const layoutEdges = layoutKind ? allEdges.filter((e) => e.kind === layoutKind) : allEdges;
// Compute a fallback layout only when something actually needs one. A graph everyone has
// already dragged pays nothing for this.
const missingPosition = entries.some(([id]) => !input.layout[id]);
const computed = missingPosition ? computeGraphLayout(input, layoutEdges) : {};
const toPersist: Record<string, GraphPosition> = {};
const nodes: GraphFlowNode[] = entries.map(([id, node]) => {
const stored = input.layout[id];
const position = stored ?? computed[id] ?? { x: 0, y: 0 };
if (!stored) toPersist[id] = position;
const lint = input.lintByNode?.[id];
const data: GraphNodeData = {
node,
// The pointed-at document's name. Falls back to the document id rather than to the node
// id, because a node id names nothing outside this one graph.
title: node.title?.trim() || node.documentId,
nodeSchema: input.schema.nodeSchema,
...(input.hydrated?.[id] ? { hydrated: input.hydrated[id] } : {}),
...(lint && lint.length > 0 ? { lint } : {}),
...(input.missingDocumentIds?.has(id) ? { missingDocument: true } : {}),
...(nodeColorOf(input.schema, node) ? { color: nodeColorOf(input.schema, node) } : {}),
};
return { id, type: GRAPH_NODE_TYPE, position, data };
});
const kindByKey = new Map(input.schema.edgeKinds.map((k) => [k.key, k]));
const edges: GraphFlowEdge[] = allEdges
.filter((edge) => !hidden.has(edge.kind))
.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: GRAPH_EDGE_TYPE,
// The kind's own label, drawn only when the edge has nothing more specific to say.
label: edge.label ?? kindByKey.get(edge.kind)?.label ?? edge.kind,
data: {
kind: edge.kind,
...(kindByKey.get(edge.kind) ? { edgeKind: kindByKey.get(edge.kind) } : {}),
edge,
// The same predicate that chose `layoutEdges`, so what RANKS the chart and what draws
// as its skeleton can never be two different sets. With no axis declared every relation
// ranks, and every relation therefore draws as structure — which is honest: the layout
// really did use all of them.
...(!layoutKind || edge.kind === layoutKind ? { hierarchy: true } : {}),
},
}));
return { nodes, edges, toPersist };
}
/**
* One value, resolved in the order the design fixes: `hydrated ?? derived ?? fields`.
*
* That order is the whole contract between the two halves of a node. An AUTHORED value is shown
* as authored; a BOUND value is shown live once hydration lands; and the cache only ever fills
* the gap between open and arrival. The three can disagree in exactly one direction — toward the
* document — and never the other way.
*/
export function resolveNodeValue(data: GraphNodeData, key=[redacted] unknown {
if (data.hydrated && key in data.hydrated) return data.hydrated[key];
if (data.node.derived && key in data.node.derived) return data.node.derived[key];
return data.node.fields[key];
}