import { DO_GROUP_CHILD_WIDTH, DO_GROUP_HEADER, DO_GROUP_INNER_COLS_MAX, DO_GROUP_MIN_SIZE, DO_GROUP_PAD, PICK_GROUP_MIN_SIZE, NODE_GAP, NODE_HEIGHT, NODE_WIDTH, } from "./traceFlowConstants"; import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; import { TraceGraphNode } from "./buildTraceGraphNodes"; export type DoGroupChildPlacement = { x: number; y: number; compact: boolean }; /** Fewer columns for large groups — wider cards, no horizontal cramming. */ const preferredDoGroupCols = (memberCount: number): number => { if (memberCount <= 1) return 1; if (memberCount >= 15) return 2; if (memberCount >= 6) return 3; return 2; }; const sumDoGroupQty = ( children: TraceGraphLayoutNode[], ): { totalQty: number; uom?: string } | null => { let totalQty = 0; let hasQty = false; let uom: string | undefined; children.forEach((child) => { const qty = child.qty; if (qty != null && Number.isFinite(Number(qty))) { totalQty += Number(qty); hasQty = true; } if (!uom && child.uom?.trim()) uom = child.uom.trim(); }); return hasQty ? { totalQty, uom } : null; }; export const computeDoGroupBoxLayout = ( memberCount: number, ): { width: number; height: number; cols: number } => { const childW = DO_GROUP_CHILD_WIDTH; const cols = Math.min(DO_GROUP_INNER_COLS_MAX, memberCount, preferredDoGroupCols(memberCount)); const rows = Math.ceil(memberCount / cols); const gridW = cols * childW + Math.max(0, cols - 1) * NODE_GAP; const gridH = rows * NODE_HEIGHT + Math.max(0, rows - 1) * NODE_GAP; return { width: gridW + DO_GROUP_PAD * 2, height: DO_GROUP_HEADER + gridH + DO_GROUP_PAD * 2, cols, }; }; export const layoutDoGroupChildPlacements = ( children: TraceGraphLayoutNode[], ): Map => { const sorted = [...children].sort(sortNodesInPhase); const { cols } = computeDoGroupBoxLayout(sorted.length); const childW = DO_GROUP_CHILD_WIDTH; const result = new Map(); sorted.forEach((child, index) => { const col = index % cols; const row = Math.floor(index / cols); result.set(child.id, { x: DO_GROUP_PAD + col * (childW + NODE_GAP), y: DO_GROUP_HEADER + DO_GROUP_PAD + row * (NODE_HEIGHT + NODE_GAP), compact: false, }); }); return result; }; export const applyDoOutboundGrouping = ( nodes: TraceGraphLayoutNode[], groupTitle: (count: number) => string, minSize = DO_GROUP_MIN_SIZE, ): TraceGraphLayoutNode[] => { const doByColumnAndWarehouse = new Map(); nodes.forEach((node) => { if (node.kind !== "DO_OUT" || node.doGroupId) return; const whKey = (node.warehouseCode ?? "").trim().toUpperCase() || "__none__"; const key = `${node.column}::${whKey}`; const list = doByColumnAndWarehouse.get(key) ?? []; list.push(node); doByColumnAndWarehouse.set(key, list); }); const groupNodes: TraceGraphLayoutNode[] = []; const childGroupIds = new Map(); doByColumnAndWarehouse.forEach((columnNodes, key) => { if (columnNodes.length < minSize) return; const column = columnNodes[0]!.column; const warehouseCode = columnNodes[0]!.warehouseCode?.trim() || undefined; const whSlug = (warehouseCode ?? "none").replace(/[^a-zA-Z0-9_-]/g, "_"); const groupId = `do-group-col-${column}-${whSlug}`; const sorted = [...columnNodes].sort(sortNodesInPhase); const { width, height } = computeDoGroupBoxLayout(sorted.length); const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); const qtySummary = sumDoGroupQty(sorted); sorted.forEach((child) => childGroupIds.set(child.id, groupId)); const groupNode: TraceGraphLayoutNode = { id: groupId, kind: "DO_GROUP", timestamp: sorted[0]?.timestamp ?? null, sortKey: minSort, title: groupTitle(sorted.length), subtitle: [sorted[0]?.dayKey, warehouseCode].filter(Boolean).join(" · ") || "", categoryLabel: sorted[0]?.categoryLabel, warehouseCode, details: [], phase: sorted[0]!.phase, column, dayKey: sorted[0]!.dayKey, laneIndex: sorted[0]!.laneIndex, sequenceIndex: sorted[0]!.sequenceIndex, branchIndex: 0, branchSize: 1, dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, groupBoxWidth: width, groupBoxHeight: height, groupMemberCount: sorted.length, groupTotalQty: qtySummary?.totalQty, groupUom: qtySummary?.uom, }; groupNodes.push(groupNode); }); if (!groupNodes.length) return nodes; return [ ...nodes.map((node) => { const groupId = childGroupIds.get(node.id); if (!groupId) return node; return { ...node, doGroupId: groupId }; }), ...groupNodes, ].sort((a, b) => { if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; return a.id.localeCompare(b.id); }); }; export const applyMaterialPickGrouping = ( nodes: TraceGraphLayoutNode[], groupTitle: (pickOrderCode: string, count: number) => string, minSize = PICK_GROUP_MIN_SIZE, ): TraceGraphLayoutNode[] => { const picksByKey = new Map(); nodes.forEach((node) => { if (node.kind !== "MATERIAL_PICK" || node.doGroupId) return; const pickCode = node.refCode?.trim(); if (!pickCode) return; const key = `${node.column}::${pickCode}`; const list = picksByKey.get(key) ?? []; list.push(node); picksByKey.set(key, list); }); const groupNodes: TraceGraphLayoutNode[] = []; const childGroupIds = new Map(); picksByKey.forEach((columnNodes, key) => { if (columnNodes.length < minSize) return; const pickCode = key.split("::").slice(1).join("::"); const groupId = `pick-group-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`; const sorted = [...columnNodes].sort(sortNodesInPhase); const { width, height } = computeDoGroupBoxLayout(sorted.length); const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); sorted.forEach((child) => childGroupIds.set(child.id, groupId)); const groupNode: TraceGraphLayoutNode = { id: groupId, kind: "PICK_GROUP", timestamp: sorted[0]?.timestamp ?? null, sortKey: minSort, title: groupTitle(pickCode, sorted.length), subtitle: sorted[0]?.dayKey ?? "", refCode: pickCode, refId: sorted[0]?.refId, docLinkKind: "pick", consoCode: sorted[0]?.consoCode, jobOrderCode: sorted[0]?.jobOrderCode, categoryLabel: sorted[0]?.categoryLabel, details: [], phase: sorted[0]!.phase, column: sorted[0]!.column, dayKey: sorted[0]!.dayKey, laneIndex: sorted[0]!.laneIndex, sequenceIndex: sorted[0]!.sequenceIndex, branchIndex: 0, branchSize: 1, dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, groupBoxWidth: width, groupBoxHeight: height, groupMemberCount: sorted.length, }; groupNodes.push(groupNode); }); if (!groupNodes.length) return nodes; return [ ...nodes.map((node) => { const groupId = childGroupIds.get(node.id); if (!groupId) return node; return { ...node, doGroupId: groupId }; }), ...groupNodes, ].sort((a, b) => { if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; return a.id.localeCompare(b.id); }); }; export const applyFlowNodeGrouping = ( nodes: TraceGraphLayoutNode[], labels: { flowDoGroupTitle: (count: number) => string; flowPickGroupTitle: (pickOrderCode: string, count: number) => string; }, ): TraceGraphLayoutNode[] => applyMaterialPickGrouping( applyDoOutboundGrouping(nodes, labels.flowDoGroupTitle), labels.flowPickGroupTitle, ); export const regroupLayoutCells = ( nodes: TraceGraphLayoutNode[], ): TraceGraphLayoutNode[][] => { const visible = nodes.filter((n) => !isDoGroupChild(n)); const groups = new Map(); visible.forEach((node) => { const key = `${node.column}:${node.laneIndex}`; const list = groups.get(key) ?? []; list.push(node); groups.set(key, list); }); groups.forEach((list) => { list.sort(sortNodesInPhase); const size = list.length; list.forEach((node, idx) => { node.branchIndex = idx; node.branchSize = size; }); }); return Array.from(groups.values()).sort((a, b) => { const na = a[0]!; const nb = b[0]!; if (na.column !== nb.column) return na.column - nb.column; return na.laneIndex - nb.laneIndex; }); }; export const isFlowGroupContainer = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => node.kind === "DO_GROUP" || node.kind === "PICK_GROUP"; export const isDoGroupChild = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => Boolean(node.doGroupId?.trim());