Files
SKEEN-Proyecto/wacrm/src/lib/flows/layout.ts
Consultoría Alcaraz Salazar a718592291 Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- 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/
2026-07-20 07:44:23 +00:00

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