- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
/**
|
|
* Dagre-based auto-layout for the flow canvas.
|
|
*
|
|
* The canvas reads `flow_nodes.position_x` / `position_y` (added in
|
|
* migration 010 as `INTEGER NOT NULL DEFAULT 0` — reserved precisely
|
|
* for this view). Brand-new flows and every flow authored before the
|
|
* canvas shipped have all-zero positions, which would render as a
|
|
* single overlapping pile at the origin. This module computes
|
|
* reasonable starting positions in those cases.
|
|
*
|
|
* Why dagre over a hand-rolled BFS layout: branches with multiple
|
|
* outgoing edges (send_buttons, condition, send_list) need horizontal
|
|
* spread to be readable, and dagre's `rank`+`order` pass handles edge
|
|
* crossings far better than anything we'd write by hand. ~30 KB gz
|
|
* for the standalone wrapper, but the canvas already pulls in
|
|
* @xyflow/react so this is incremental.
|
|
*
|
|
* What we do NOT do here: re-layout on every edit. The canvas
|
|
* persists the user's drag positions, and we only ever auto-layout
|
|
* once when `shouldAutoLayout()` returns true. Otherwise a user who
|
|
* carefully arranged a flow would have their work overwritten on
|
|
* reload.
|
|
*/
|
|
|
|
import Dagre from "@dagrejs/dagre";
|
|
|
|
export interface LayoutNode {
|
|
id: string;
|
|
/** Optional measured size — falls back to defaults if not provided. */
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export interface LayoutEdge {
|
|
source: string;
|
|
target: string;
|
|
}
|
|
|
|
export interface LayoutPosition {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface LayoutOptions {
|
|
/** Top-to-bottom is the natural reading order for conversation flows. */
|
|
direction?: "TB" | "LR";
|
|
/** Gap between rows (TB) / columns (LR). */
|
|
rankSep?: number;
|
|
/** Gap between sibling nodes within the same rank. */
|
|
nodeSep?: number;
|
|
/** Default node width when a node's width isn't measured yet. */
|
|
defaultWidth?: number;
|
|
/** Default node height when a node's height isn't measured yet. */
|
|
defaultHeight?: number;
|
|
}
|
|
|
|
const DEFAULTS: Required<LayoutOptions> = {
|
|
direction: "TB",
|
|
rankSep: 80,
|
|
nodeSep: 60,
|
|
defaultWidth: 240,
|
|
defaultHeight: 90,
|
|
};
|
|
|
|
/**
|
|
* True iff every node sits at the origin — the signal that no human
|
|
* has positioned this flow yet and auto-layout is safe to run.
|
|
*
|
|
* Why `every`, not `some`: a partially-laid-out flow (some nodes at
|
|
* 0,0, others positioned) is almost certainly mid-edit. Re-running
|
|
* dagre would shuffle the positioned ones the user already chose.
|
|
* Better to leave the new nodes at 0,0 and let the user drag them.
|
|
*/
|
|
export function shouldAutoLayout(
|
|
nodes: Array<{ position_x?: number | null; position_y?: number | null }>,
|
|
): boolean {
|
|
if (nodes.length === 0) return false;
|
|
return nodes.every(
|
|
(n) => (n.position_x ?? 0) === 0 && (n.position_y ?? 0) === 0,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compute positions for every node id. Returns a map keyed by node
|
|
* id; consumers merge it into their React-Flow nodes array. The
|
|
* returned coordinates are the TOP-LEFT corner (matches React-Flow's
|
|
* coordinate space — dagre internally tracks centers, we translate).
|
|
*/
|
|
export function autoLayout(
|
|
nodes: LayoutNode[],
|
|
edges: LayoutEdge[],
|
|
options: LayoutOptions = {},
|
|
): Map<string, LayoutPosition> {
|
|
const opts = { ...DEFAULTS, ...options };
|
|
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
|
g.setGraph({
|
|
rankdir: opts.direction,
|
|
ranksep: opts.rankSep,
|
|
nodesep: opts.nodeSep,
|
|
});
|
|
|
|
for (const n of nodes) {
|
|
g.setNode(n.id, {
|
|
width: n.width ?? opts.defaultWidth,
|
|
height: n.height ?? opts.defaultHeight,
|
|
});
|
|
}
|
|
for (const e of edges) {
|
|
// Dagre tolerates edges to/from non-existent nodes by inserting
|
|
// them as zero-size — that would silently warp the layout. Skip
|
|
// dangling edges instead; the canvas's edge derivation already
|
|
// filters them but defending here keeps this helper standalone.
|
|
if (g.node(e.source) && g.node(e.target)) {
|
|
g.setEdge(e.source, e.target);
|
|
}
|
|
}
|
|
|
|
Dagre.layout(g);
|
|
|
|
const positions = new Map<string, LayoutPosition>();
|
|
for (const n of nodes) {
|
|
const laid = g.node(n.id);
|
|
if (!laid) continue;
|
|
// Dagre returns the center; React-Flow wants the top-left.
|
|
positions.set(n.id, {
|
|
x: laid.x - (n.width ?? opts.defaultWidth) / 2,
|
|
y: laid.y - (n.height ?? opts.defaultHeight) / 2,
|
|
});
|
|
}
|
|
return positions;
|
|
}
|