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];
}