workflow-migration-geometry.mjs
161 lines · archify/renderers/workflow/workflow-migration-geometry.mjs at a406594
1const TARGET_SCHEMA_VERSION = 2;
3// Planning removes absolute route assertions, but must keep the same rank
4// policy as the document whose coordinates will be mapped into that plan.
5// An authored canvas likewise keeps its established plan: draft-only optional
6// corridor growth must not consume space that a valid fixed canvas lacks.
7const absoluteGeometryPolicy = Symbol('workflow absolute geometry policy');
8export function preservesWorkflowAbsoluteGeometry(workflow) {
9 return workflow[absoluteGeometryPolicy] ?? (Array.isArray(workflow.meta?.viewBox) || (workflow.edges || []).some((edge) => (
10 Array.isArray(edge.via) || Array.isArray(edge.labelAt)
11 || edge.channelX !== undefined || edge.channelY !== undefined
12 )));
13}
15function clone(value) {
16 return JSON.parse(JSON.stringify(value));
17}
19/**
20 * Return the authored workflow as a schema-v2 document without its capacity
21 * override. The compiler can use this projection to discover the intrinsic v2
22 * rank plan before deciding whether an explicit viewBox needs to grow.
23 */
24export function intrinsicWorkflow(workflow) {
25 const intrinsic = clone(workflow);
26 intrinsic.schema_version = TARGET_SCHEMA_VERSION;
27 intrinsic[absoluteGeometryPolicy] = preservesWorkflowAbsoluteGeometry(workflow);
28 intrinsic.meta = { ...intrinsic.meta };
29 delete intrinsic.meta.viewBox;
30 return intrinsic;
31}
33/**
34 * Return a schema-v2 planning projection that removes authored route geometry
35 * which may only become valid after its legacy X coordinates are remapped.
36 * Rank-affecting automatic and straight relationships remain in the projection.
37 */
38export function planningWorkflow(workflow) {
39 const planned = intrinsicWorkflow(workflow);
40 planned.edges = planned.edges.flatMap((edge) => {
41 const hasRoutedGeometry = Array.isArray(edge.via)
42 || (edge.route && !['auto', 'straight'].includes(edge.route))
43 || edge.channelX !== undefined
44 || edge.channelY !== undefined;
45 if (hasRoutedGeometry) return [];
47 const automatic = {};
48 for (const property of ['id', 'from', 'to', 'variant', 'role', 'width']) {
49 if (edge[property] !== undefined) automatic[property] = edge[property];
50 }
51 if (edge.route === 'straight') automatic.route = 'straight';
52 if (edge.labelAt === undefined && edge.label !== undefined) automatic.label = edge.label;
53 return [automatic];
54 });
56 // Full-topology contracts cannot be checked against a layout-only subgraph.
57 // The source and final mapped candidate retain and validate these contracts.
58 if (planned.edges.length !== workflow.edges.length) delete planned.semanticChecks;
60 if (Array.isArray(planned.mainPath)) {
61 const projectedPairs = new Set(planned.edges.map((edge) => `${edge.from}\u0000${edge.to}`));
62 const projectionBreaksMainPath = planned.mainPath.some((from, index) => (
63 index < planned.mainPath.length - 1
64 && !projectedPairs.has(`${from}\u0000${planned.mainPath[index + 1]}`)
65 ));
66 if (projectionBreaksMainPath) delete planned.mainPath;
67 }
69 return planned;
70}
72function mappedNumber(value) {
73 return Number(value.toFixed(6));
74}
76/**
77 * Build a deterministic piecewise-linear mapping between corresponding legacy
78 * and readable rank centers. Coordinates outside the rank span are extrapolated
79 * using the nearest segment so explicitly authored outside corridors retain
80 * their relative offset.
81 */
82export function createHorizontalRankMapper(oldColumns, newColumns) {
83 if (
84 !Array.isArray(oldColumns)
85 || !Array.isArray(newColumns)
86 || oldColumns.length !== newColumns.length
87 || oldColumns.length < 2
88 || !oldColumns.every(Number.isFinite)
89 || !newColumns.every(Number.isFinite)
90 ) {
91 throw new TypeError('Horizontal rank mapping requires matching finite column arrays.');
92 }
93 for (let index = 1; index < oldColumns.length; index += 1) {
94 if (oldColumns[index] <= oldColumns[index - 1] || newColumns[index] <= newColumns[index - 1]) {
95 throw new TypeError('Horizontal rank mapping requires strictly increasing columns.');
96 }
97 }
99 return (x) => {
100 if (!Number.isFinite(x)) throw new TypeError('Horizontal rank mapping requires a finite x coordinate.');
101 let segment = oldColumns.length - 2;
102 if (x <= oldColumns[0]) {
103 segment = 0;
104 } else {
105 for (let index = 0; index < oldColumns.length - 1; index += 1) {
106 if (x <= oldColumns[index + 1]) {
107 segment = index;
108 break;
109 }
110 }
111 }
112 const oldSpan = oldColumns[segment + 1] - oldColumns[segment];
113 const newSpan = newColumns[segment + 1] - newColumns[segment];
114 const ratio = (x - oldColumns[segment]) / oldSpan;
115 return mappedNumber(newColumns[segment] + ratio * newSpan);
116 };
117}
119/**
120 * Apply one horizontal coordinate mapping to every schema-v1 absolute X pin.
121 * The caller owns the supplied workflow; this function reports an audit trail
122 * for each changed coordinate in stable document order.
123 */
124export function mapExplicitCoordinates(workflow, mapX) {
125 const changedCoordinates = [];
126 const record = (path, owner, property) => {
127 const from = owner[property];
128 const to = mapX(from);
129 owner[property] = to;
130 if (to !== from) changedCoordinates.push({ path, from, to });
131 };
133 for (const [edgeIndex, edge] of workflow.edges.entries()) {
134 if (Array.isArray(edge.via)) {
135 for (const [pointIndex, point] of edge.via.entries()) {
136 if (Array.isArray(point) && Number.isFinite(point[0])) {
137 record(`/edges/${edgeIndex}/via/${pointIndex}/0`, point, 0);
138 }
139 }
140 }
141 if (Array.isArray(edge.labelAt) && Number.isFinite(edge.labelAt[0])) {
142 record(`/edges/${edgeIndex}/labelAt/0`, edge.labelAt, 0);
143 }
144 if (Number.isFinite(edge.channelX)) {
145 record(`/edges/${edgeIndex}/channelX`, edge, 'channelX');
146 }
147 }
148 return changedCoordinates;
149}
151/**
152 * Construct an independently owned schema-v2 candidate with all authored
153 * absolute X pins mapped to the readable rank plan.
154 */
155export function createMappedWorkflowCandidate(workflow, oldColumns, newColumns) {
156 const document = clone(workflow);
157 document.schema_version = TARGET_SCHEMA_VERSION;
158 const mapX = createHorizontalRankMapper(oldColumns, newColumns);
159 const changedCoordinates = mapExplicitCoordinates(document, mapX);
160 return { document, changedCoordinates };
161}