diff --git a/docs/flowchart-zones.md b/docs/flowchart-zones.md index eff8e9b9..23b4440c 100644 --- a/docs/flowchart-zones.md +++ b/docs/flowchart-zones.md @@ -16,7 +16,7 @@ Additive to [flowchart groups](./flowchart-groups.md): - `groups[].parent` names the declared group a group sits inside. It mirrors `parent` on `buildStateMachine` states, and an explicit field is the contract the groups plan asked for: nesting is never read out of ids or labels. - `groups[].kind` is one of `cloud`, `datacenter`, `region`, `zone`, `vpc`, `subnet`, `cluster` or `namespace`. It says what a zone stands for and sets how it draws. It is a closed set so a model picks a drawn treatment, not free text. -- `nodes[].shape` is one of `database`, `queue`, `document` or `storage`. It is separate from `kind`, which keeps meaning start, process, decision or end, so a flowchart's start, end and reachability rules hold unchanged: a database a request ends at is `kind: "end", shape: "database"`. +- `nodes[].shape` is one of `database`, `queue`, `document` or `storage`. It is separate from `kind`, which keeps meaning start, process, decision or end, so a flowchart's start, end and reachability rules hold unchanged: a database a request ends at is `kind: "end", shape: "database"`. Before it validates, a build reads a shaped node's end from its edges: a shaped `process` nothing leaves builds as an `end`, and a shaped `end` that passes data on (a queue its workers read) as a `process` where another end remains, so neither costs the caller a retry. - A node sets `group` to its innermost zone. A zone holds nodes directly or through the zones nested in it. - Zones nest at most 4 deep (a cloud, a region, a VPC and a subnet), and a flowchart keeps at most 8 groups. @@ -43,13 +43,15 @@ Each zone's label sits at the left of a header strip across its top; nothing the - Each zone's members and nested zones are laid out as a flow of their own, innermost first; a nested zone is one block in its parent's flow, the way an outermost zone is one block among the ungrouped nodes. Children stay inside their parents and zones that do not nest never overlap. - Edges route around every zone neither end sits in and never cross a zone's label or run along its outline. An edge whose ends share a zone stays inside the innermost zone holding both. - Zones keep the diagram's direction at every level: zones a flow passes through one after another follow it, and zones it does not join stand side by side across it. -- A zoned flow too long for its breadth (2.45:1 for a row at least 1,200 px long or for any column, 4:1 always) wraps as a plain flowchart wraps into rows or columns, never by turning a zone across the flow. Level by level, the top level or one zone's members cut between whole ranks into bands, each read in the stated direction, columns keeping the plain rule's even-split guard. A band hangs under the step that leads into it, as far as the longest band allows, and lines into it turn in from that side. Of the plans that route with no more crossings, export defects or reader findings other than port jogs, the first adding nothing is kept, else the one with the fewest crossings, then findings, then the squarest. The hybrid fixture goes from 4.8:1 to 1.7:1, the AWS one from 2.8:1 to 1.7:1. +- A zoned flow too long for its breadth (2.45:1 for a row at least 1,200 px long or for any column, 4:1 always) wraps as a plain flowchart wraps into rows or columns, never by turning a zone across the flow. Level by level, the top level or one zone's members cut between whole ranks into bands, each read in the stated direction, columns keeping the plain rule's even-split guard. A band hangs under the step that leads into it, as far as the longest band allows, and lines into it turn in from that side. Of the plans that route with no more crossings, export defects or reader findings other than port jogs, the first adding nothing is kept, else the one with the fewest crossings, then findings, then the squarest. The hybrid fixture goes from 4.8:1 to 1.5:1, the AWS one from 2.8:1 to 1.7:1. +- In a row, a plan within the target that keeps the top-level zones side by side, wrapping inside one of them, goes before a plan that cuts the top level between two zones. That cut hangs the later zone under the earlier, though the flow passes from one to the next along the row, and the line between them runs down and back against it. The stacked plan stays the first alternative, so a side-by-side plan that routes worse still gives way to it. A column's cut stands the later zone beside the earlier, across a wide export, and columns plan as before. Two of three Sol samples of `hybrid-cloud-zones-v2`, asked for left to right, hung the AWS cloud under the data center (the top level reached the target in two bands, the region in three); the cloud now stands beside it. - A plan whose plain routes cross more than the layout it replaces is routed again, each line across as few others as it can, before it is judged, so a crossing the plain router happens to draw does not alone stack sibling zones that read side by side along the flow. - A plan that keeps the top level in one band and still crosses one line pair more is judged as the crossing repairs leave it, as the kept plan is drawn, where those leave it crossing, detouring, hooking, running through arrowheads and passing near nodes no more than the layout it replaces, each cleared of the nodes it passes, and no line in it runs more than twice its direct connection past its stubs. Such a plan only keeps sibling zones side by side: it replaces the chosen plan only where that one stacks them and it crosses and finds no more, and it never stacks zones itself. A Sol hybrid sample whose region, wrapped beside the data center, crossed the sync function's lines to the orders table and the invoice PDF once now draws beside it, the table's line entering the table from the side that faces the sync function. - A line into a hanging band whose target stands behind another step of its rank, straight across the flow from the source, leaves forward and turns back into the target's forward side, around that step, instead of hooking around the band's margin to reach its near side. This is kept when it routes no worse, hooks or detours no line more, and draws the lines shorter in total. In a round 19 hybrid sample, the line to the orders table drops straight down and the line to the invoice archive brackets around it: four turns fewer and a tenth less line. - A zone sits in its rank by its members rather than its outline, so a flow through nested zones runs straight past their header strips. - A step reached back through steps outside its zone, as workers fed by a queue the zone's API publishes to, sits beside the step that leaves; outside units in one rank sit under the members they join, a loop back pulling hardest, so the queue sits under its workers. - Among zones, no route runs along a node's border. +- Among zones, a line also keeps the reader's corridor gap (24 px at a 2,000 px fit) from a node it runs alongside but neither leaves nor enters, past a corner's length: the last clearing pass, after every other choice, moves such a run out to the gap where the move brings the line within that clearance of no node and draws nothing worse. A zone whose outline leaves no lane there grows, once every other choice is made, where that routes no worse. Sol and Sonnet hybrid samples ran the ERP's line to the VPN gateway 13 units under the Oracle database's foot, between it and the data center's outline; the data center now grows and the line runs 27 units clear. ## Excalidraw diff --git a/packages/diagram/agent/src/lib/flowchart/node-kinds.test.ts b/packages/diagram/agent/src/lib/flowchart/node-kinds.test.ts index 90e4718e..09d09849 100644 --- a/packages/diagram/agent/src/lib/flowchart/node-kinds.test.ts +++ b/packages/diagram/agent/src/lib/flowchart/node-kinds.test.ts @@ -279,6 +279,87 @@ describe("flowchart node kinds", () => { }); }); + it("ends a shaped store nothing leaves and steps through a shaped end that passes data on", async () => { + // The hybrid sample Sonnet first sent three times: the Oracle database the ERP + // stores into, a dead end, marked process. + const hybrid = await build({ + title: "Hybrid setup: on-prem to AWS", + layout: { direction: "LR" }, + groups: [ + { id: "dc", label: "On-prem data center", kind: "datacenter" }, + { id: "corp", label: "Corporate network", kind: "zone", parent: "dc" }, + { id: "aws", label: "AWS cloud", kind: "cloud" }, + { id: "reg", label: "eu-west-1", kind: "region", parent: "aws" }, + { id: "vpc", label: "Production VPC", kind: "vpc", parent: "reg" }, + { id: "priv", label: "Private subnet", kind: "subnet", parent: "vpc" }, + ], + nodes: [ + { id: "staff", label: "Office staff", kind: "start", group: "corp" }, + { id: "erp", label: "Legacy ERP", kind: "process", group: "corp" }, + { id: "ora", label: "Oracle database", kind: "process", shape: "database", group: "dc" }, + { id: "vpn", label: "Site-to-site VPN gateway", kind: "process", group: "vpc" }, + { id: "api", label: "Order API", kind: "process", group: "priv" }, + { id: "q", label: "Order queue", kind: "process", shape: "queue", group: "reg" }, + { id: "fn", label: "Sync function", kind: "process", group: "reg" }, + { id: "tbl", label: "Orders table", kind: "end", shape: "database", group: "reg" }, + { id: "arc", label: "Invoice archive", kind: "end", shape: "storage", group: "reg" }, + ], + edges: [ + { source: "staff", target: "erp", label: "enter orders" }, + { source: "erp", target: "ora", label: "stores records" }, + { source: "erp", target: "vpn" }, + { source: "vpn", target: "api" }, + { source: "api", target: "q" }, + { source: "q", target: "fn" }, + { source: "fn", target: "tbl" }, + { source: "fn", target: "arc", label: "saves invoice PDF" }, + ], + }); + if (!hybrid.ok) throw new Error(JSON.stringify(hybrid.issues)); + expect(hybrid.normalizedSpec.nodes.find((node) => node.id === "ora")?.kind).toBe("end"); + expect(hybrid.normalizedSpec.nodes.find((node) => node.id === "q")?.kind).toBe("process"); + + // The Kubernetes sample Sonnet first sent twice: RabbitMQ, which the workers + // read, marked end beside the Cloud SQL end. + const cluster = (rabbit: string) => ({ + title: "GKE cluster setup", + nodes: [ + { id: "browser", label: "Browser", kind: "start" }, + { id: "api", label: "API pods", kind: "process" }, + { id: "workers", label: "Worker pods", kind: "process" }, + { id: "rmq", label: "RabbitMQ", kind: rabbit, shape: "queue" }, + { id: "sql", label: "Cloud SQL", kind: "end", shape: "database" }, + ], + edges: [ + { source: "browser", target: "api" }, + { source: "api", target: "sql", label: "write orders" }, + { source: "api", target: "rmq", label: "publish jobs" }, + { source: "rmq", target: "workers", label: "consume jobs" }, + { source: "workers", target: "sql", label: "write results" }, + ], + }); + const built = await build(cluster("end")); + if (!built.ok) throw new Error(JSON.stringify(built.issues)); + expect(built.normalizedSpec.nodes.find((node) => node.id === "rmq")?.kind).toBe("process"); + + // The repair reads only shaped nodes: a plain step nothing leaves still needs + // its next edge, and a shaped end that is the flow's only end stays one. + expect( + kinds({ + nodes: [ + { id: "a", label: "Receive", kind: "start" }, + { id: "b", label: "Review", kind: "process" }, + { id: "c", label: "Store", kind: "end", shape: "database" }, + ], + edges: [ + { source: "a", target: "b" }, + { source: "a", target: "c" }, + { source: "c", target: "b" }, + ], + }), + ).toEqual(["start", "process", "end"]); + }); + it("leaves unknown kinds and non-flowchart input alone", () => { const input = { spec: invoice([{ id: "receive", label: "Receive", kind: "subroutine" }]) }; expect(resolveFlowchartNodeKinds(input)).toBe(input); diff --git a/packages/diagram/agent/src/lib/flowchart/node-kinds.ts b/packages/diagram/agent/src/lib/flowchart/node-kinds.ts index 08b47233..1ac3d917 100644 --- a/packages/diagram/agent/src/lib/flowchart/node-kinds.ts +++ b/packages/diagram/agent/src/lib/flowchart/node-kinds.ts @@ -28,6 +28,8 @@ const KIND_SYNONYMS: Readonly> = { }; /** Kinds that name a start or an end without saying which: the node's position decides. */ const TERMINAL_KINDS = new Set(["terminal", "terminator", "terminus"]); +/** Infrastructure shapes: a node drawn as one is a store or a channel, whatever its kind. */ +const INFRASTRUCTURE_SHAPES = new Set(["database", "document", "queue", "storage"]); interface Degree { incoming: number; @@ -41,6 +43,7 @@ interface LooseNode { readonly label?: unknown; readonly kind?: unknown; readonly type?: unknown; + readonly shape?: unknown; } interface LooseEdge { readonly source?: unknown; @@ -72,7 +75,9 @@ const looseKey = (value: string) => * several outgoing edges, keeps its value so validation asks the caller instead of turning a * would-be decision into a step. With another connected end, a non-question process leaf reached * by a labeled decision branch is also an end. Ordinary process chains still need their next edge. - * Edges written { from, to } read as { source, target }. + * A connected node drawn as an infrastructure shape is an end when nothing leaves it (a database the + * flow stores into) and, with another connected end, a process when something does (a queue its + * workers read). Edges written { from, to } read as { source, target }. */ export function resolveFlowchartNodeKinds(input: unknown): unknown { if (!isRecord(input)) return input; @@ -134,6 +139,13 @@ export function resolveFlowchartNodeKinds(input: unknown): unknown { of(node.id).incoming > 0 && of(node.id).outgoing === 0, ); + const shaped = (node: LooseNode) => + typeof node.shape === "string" && INFRASTRUCTURE_SHAPES.has(looseKey(node.shape)); + // A shaped step nothing leaves is a store the flow ends at, and ends it as surely as an end. + const storeEnd = (node: LooseNode, kind: unknown) => + kind === "process" && shaped(node) && of(node.id).incoming > 0 && of(node.id).outgoing === 0; + const endRemains = + hasConnectedEnd || nodes.some((node, index) => isRecord(node) && storeEnd(node, kinds[index])); const decisions = new Set( nodes.flatMap((node, index) => isRecord(node) && kinds[index] === "decision" ? [node.id] : [], @@ -161,6 +173,11 @@ export function resolveFlowchartNodeKinds(input: unknown): unknown { ) { kind = "end"; } + const { incoming, outgoing } = of(node.id); + if (storeEnd(node, kind)) kind = "end"; + // A shaped end that passes data on, as a queue its workers read, is a step. + else if (kind === "end" && endRemains && shaped(node) && incoming > 0 && outgoing > 0) + kind = "process"; if (kind === node.kind) return node; changed = true; const { type: _type, ...rest } = node; diff --git a/packages/diagram/core/src/fixtures/zones/hybrid-cloud-left-to-right.ts b/packages/diagram/core/src/fixtures/zones/hybrid-cloud-left-to-right.ts new file mode 100644 index 00000000..a5f9ef67 --- /dev/null +++ b/packages/diagram/core/src/fixtures/zones/hybrid-cloud-left-to-right.ts @@ -0,0 +1,153 @@ +/** + * The hybrid setup asked for left to right, whose cloud a top-level wrap hung + * under the data center, the ERP's line running down and back left to the VPN + * gateway. From a generated eval sample (Sol, the real-voice v2 prompt, #584). + */ +export default { + id: "hybrid-order-processing-aws-eu-west-1", + title: "Hybrid order processing — AWS eu-west-1", + nodes: [ + { + id: "staff", + label: "Office staff", + kind: "start", + group: "corp", + }, + { + id: "erp", + label: "Legacy ERP", + kind: "process", + group: "corp", + }, + { + id: "oracle", + label: "Oracle database", + kind: "end", + icon: { + slug: "oracle", + }, + group: "dc", + shape: "database", + }, + { + id: "vpn", + label: "Site-to-site VPN gateway", + kind: "process", + group: "vpc", + }, + { + id: "api", + label: "Order API", + kind: "process", + group: "private", + }, + { + id: "queue", + label: "Order queue", + kind: "process", + group: "region", + shape: "queue", + }, + { + id: "sync", + label: "Sync function", + kind: "process", + group: "region", + }, + { + id: "table", + label: "Orders table", + kind: "end", + group: "region", + shape: "database", + }, + { + id: "archive", + label: "Invoice archive", + kind: "end", + description: "Invoices saved as PDFs", + group: "region", + shape: "storage", + }, + ], + edges: [ + { + id: "edge-1", + source: "staff", + target: "erp", + label: "Enter orders", + }, + { + id: "edge-2", + source: "erp", + target: "oracle", + label: "Store records", + }, + { + id: "edge-3", + source: "erp", + target: "vpn", + }, + { + id: "edge-4", + source: "vpn", + target: "api", + }, + { + id: "edge-5", + source: "api", + target: "queue", + }, + { + id: "edge-6", + source: "queue", + target: "sync", + }, + { + id: "edge-7", + source: "sync", + target: "table", + }, + { + id: "edge-8", + source: "sync", + target: "archive", + label: "Save each invoice as a PDF", + }, + ], + groups: [ + { + id: "dc", + label: "On-prem data center", + kind: "datacenter", + }, + { + id: "corp", + label: "Corporate network", + parent: "dc", + }, + { + id: "aws", + label: "AWS cloud", + kind: "cloud", + }, + { + id: "region", + label: "eu-west-1", + parent: "aws", + kind: "region", + }, + { + id: "vpc", + label: "Production VPC", + parent: "region", + kind: "vpc", + }, + { + id: "private", + label: "Private subnet", + parent: "vpc", + kind: "subnet", + }, + ], +} as const; diff --git a/packages/diagram/core/src/routing-quality.ts b/packages/diagram/core/src/routing-quality.ts index a337744c..4c3d46eb 100644 --- a/packages/diagram/core/src/routing-quality.ts +++ b/packages/diagram/core/src/routing-quality.ts @@ -62,6 +62,11 @@ export function routingCorridorGap(scale: number): number { return CORRIDOR_GAP_PX / scale; } +/** Scene-unit length from which side-by-side runs read as a corridor rather than a corner at this scale. */ +export function routingCorridorRun(scale: number): number { + return CORRIDOR_RUN_PX / scale; +} + /** Scene-unit reach from an end within which a kink reads as a port jog at this scale. */ export function routingJogReach(scale: number): number { return JOG_WINDOW_PX / scale; diff --git a/packages/diagram/core/src/zone-fixtures.ts b/packages/diagram/core/src/zone-fixtures.ts index 857418da..b574f7c4 100644 --- a/packages/diagram/core/src/zone-fixtures.ts +++ b/packages/diagram/core/src/zone-fixtures.ts @@ -10,6 +10,7 @@ import hybridCloudBypass from "./fixtures/zones/hybrid-cloud-bypass.js"; import hybridCloudBypassArchive from "./fixtures/zones/hybrid-cloud-bypass-archive.js"; import hybridCloudDescribed from "./fixtures/zones/hybrid-cloud-described.js"; import hybridCloudLabeled from "./fixtures/zones/hybrid-cloud-labeled.js"; +import hybridCloudLeftToRight from "./fixtures/zones/hybrid-cloud-left-to-right.js"; import hybridCloudStacked from "./fixtures/zones/hybrid-cloud-stacked.js"; import hybridCloudSync from "./fixtures/zones/hybrid-cloud-sync.js"; import kindedFlat from "./fixtures/zones/kinded-flat.js"; @@ -92,6 +93,13 @@ export const stackedHybridFlowchart = flowchart(hybridCloudStacked); */ export const corridorHybridFlowchart = flowchart(hybridCloudArchiveCorridor); +/** + * The hybrid setup asked for left to right, whose cloud a top-level wrap hung + * under the data center, as a generated sample drew it. Its logo needs an + * embedded asset to export. + */ +export const leftToRightHybridFlowchart = flowchart(hybridCloudLeftToRight); + /** Zones of the thirty-node stress case, each with its parent and kind. */ const STRESS_ZONES = [ { id: "aws", label: "AWS", kind: "cloud" }, diff --git a/packages/diagram/renderer/src/routing-work.test.ts b/packages/diagram/renderer/src/routing-work.test.ts index 5f5a2b3e..a6eb4d55 100644 --- a/packages/diagram/renderer/src/routing-work.test.ts +++ b/packages/diagram/renderer/src/routing-work.test.ts @@ -326,6 +326,6 @@ describe("zone routing work", () => { const large = render(zoneStressFixtures); expect(small).toBeGreaterThan(0); expect(small).toBeLessThan(2_300_000); - expect(large).toBeLessThan(18_000_000); + expect(large).toBeLessThan(24_000_000); }); }); diff --git a/packages/diagram/renderer/src/scene.ts b/packages/diagram/renderer/src/scene.ts index 9b753999..46150890 100644 --- a/packages/diagram/renderer/src/scene.ts +++ b/packages/diagram/renderer/src/scene.ts @@ -48,6 +48,7 @@ import { arrowheadsNear, candidateRouteQuality, routingCorridorGap, + routingCorridorRun, routingJogReach, routingQualityFindings, routingQualityScale, @@ -1515,6 +1516,11 @@ interface Arrangement { * its rank, straight across the flow from the source, goes around that step. */ readonly aroundRanks?: boolean; + /** + * Zones grown by this much on every side, by group id, so a line alongside + * a node they hold has a lane the corridor gap from it (zonesWithoutAlongsideRoom). + */ + readonly zoneSlack?: ReadonlyMap; readonly wrap: Wrap; /** Bands of a zoned flow's levels, by group id or TOP_LEVEL (see planZoneWraps). */ readonly zoneWraps?: ReadonlyMap; @@ -1790,7 +1796,8 @@ const ZONE_WRAP_ROUNDS = 2; * Levels wrap one at a time: * each time the level and band count that bring the whole diagram within * WRAP_TARGET_ASPECT with the fewest bands or, if none does, nearest it, while - * that keeps helping. The single-level plans that come next are alternatives, + * that keeps helping; a row keeps its top-level zones side by side where a plan + * within the target can. The single-level plans that come next are alternatives, * since a plan may route worse than the layout it replaces. Planning routes * nothing: it measures the unrouted layout, scaled by how far the routed one * (`drawn`) outgrows it, since routes widen zones for their stubs. @@ -1829,6 +1836,22 @@ function planZoneWraps( ? left.bands - right.bands || left.aspect - right.aspect : left.aspect - right.aspect; }; + // In a row, a top-level cut between two zones hangs the later zone under the + // earlier, though the flow passes from one to the next along the row. A + // column's cut stands it beside the earlier one instead, across a wide + // export, where keeping both in one column would wrap the zone inside it + // into a taller one. + const row = diagram.layout.direction === "LR" || diagram.layout.direction === "RL"; + const stacksZones = ({ next }: Plan) => { + const top = next.get(TOP_LEVEL); + if (!structure.zoned || !row || !top) return false; + const bands = new Set( + structure.groups.flatMap(({ group }) => + structure.parentOf.has(group.id) ? [] : [top.bandOf.get(groupUnitId(group.id))], + ), + ); + return bands.size > 1; + }; const planned = wraps.size; let current = predict(wraps); const alternatives: Map[] = []; @@ -1851,6 +1874,13 @@ function planZoneWraps( } } const helping = plans.filter((plan) => plan.aspect < current.aspect).sort(rank); + // A plan within the target that keeps the zones side by side goes first, + // the stacking plan it passes its first alternative. + const sideBySide = helping.findIndex( + (plan) => plan.aspect <= WRAP_TARGET_ASPECT && !stacksZones(plan), + ); + if (sideBySide > 0 && stacksZones(helping[0]!)) + helping.unshift(...helping.splice(sideBySide, 1)); const [chosen] = helping; if (!chosen) break; if (wraps.size === planned) @@ -4909,6 +4939,60 @@ function routeClearanceCount( return near; } +/** + * In zones, how near a line may run alongside a node it neither leaves nor + * enters: the reader's corridor gap, along more than a corner's length, so the + * line never reads as touching the node where it squeezes past it inside a + * zone. Outside zones only NODE_CLEARANCE holds. + */ +interface Alongside { + readonly gap: number; + readonly run: number; +} + +function alongsideReach({ + edgeArrows, + groups, + nodeShapes, +}: Pick): Alongside | undefined { + if (!groups?.zoned) return undefined; + const scale = routingQualityScale(drawnSize([...nodeShapes, ...groupBlocks(groups)], edgeArrows)); + return { gap: routingCorridorGap(scale), run: routingCorridorRun(scale) }; +} + +/** Whether a run lies alongside a box's side, outside it but nearer than the gap, past a corner's length. */ +function segmentAlongside(segment: AxisAlignedSegment, box: Box, { gap, run }: Alongside): boolean { + const horizontal = segment.orientation === "horizontal"; + const [low, size, along, length] = horizontal + ? [box.y, box.height, box.x, box.width] + : [box.x, box.width, box.y, box.height]; + const at = segment.staticCoordinate; + const distance = at < low ? low - at : at - low - size; + return ( + distance > 0 && + distance < gap && + Math.min(segment.max, along + length) - Math.max(segment.min, along) > run + ); +} + +/** Middle runs of a route that lie alongside a node it neither leaves nor enters. */ +function routeAlongsideCount( + points: readonly ScenePoint[], + shapes: readonly NodeSceneElement[], + ignoredNodeIds: ReadonlySet, + alongside: Alongside, +): number { + return segmentsFromPoints(straightRuns(points)).filter( + (segment) => + !segment.isFirstSegment && + !segment.isLastSegment && + shapes.some( + (shape) => + keepsClearOf(shape, ignoredNodeIds) && segmentAlongside(segment, shape, alongside), + ), + ).length; +} + function routeLength(points: readonly ScenePoint[]): number { let length = 0; @@ -6323,7 +6407,8 @@ function lanesClearOfNeighbors( /** * Variants of a route whose middle runs that pass too close to a node's - * outline move just clear of it, to either side of the node. A run moves whole, + * outline move just clear of it, to either side of the node; given the + * corridor gap, those that run alongside a node move out by the gap. A run moves whole, * from turn to turn, so the runs beside it stay straight, and a variant that * shortens either endpoint stub or turns it back is not offered. */ @@ -6331,6 +6416,7 @@ function runsClearOfOutlines( points: readonly [ScenePoint, ...ScenePoint[]], shapes: readonly NodeSceneElement[], ignoredNodeIds: ReadonlySet, + alongside?: Alongside, ): [ScenePoint, ...ScenePoint[]][] { const corners = straightRuns(points); const variants: [ScenePoint, ...ScenePoint[]][] = []; @@ -6340,12 +6426,15 @@ function runsClearOfOutlines( if ( !keepsClearOf(shape, ignoredNodeIds) || segmentCrossesBoundsInterior(run, shape) || - !segmentNearOutline(run, shape, NODE_CLEARANCE) + !(alongside + ? segmentAlongside(run, shape, alongside) + : segmentNearOutline(run, shape, NODE_CLEARANCE)) ) continue; + const clearance = alongside ? alongside.gap : NODE_CLEARANCE; const [low, size] = run.orientation === "horizontal" ? [shape.y, shape.height] : [shape.x, shape.width]; - for (const lane of [low - NODE_CLEARANCE - 1, low + size + NODE_CLEARANCE + 1]) { + for (const lane of [low - clearance - 1, low + size + clearance + 1]) { const moved = corners.map((point, index) => index === run.segmentIndex || index === run.segmentIndex + 1 ? run.orientation === "horizontal" @@ -7561,6 +7650,44 @@ function crowdedGroups(layout: DiagramLayout): Set { return crowded; } +/** + * Zones where a line runs alongside a node they hold, nearer than the corridor + * gap, and the zone's outline leaves no lane the gap from the node there for + * clearOutlines to move it to. + */ +function zonesWithoutAlongsideRoom(layout: DiagramLayout): Set { + const { edgeArrows, groups, nodeShapes } = layout; + const roomless = new Set(); + const alongside = alongsideReach(layout); + if (!groups || !alongside) return roomless; + for (const arrow of edgeArrows) { + const ends = new Set([arrow.sourceNodeId, arrow.targetNodeId]); + for (const run of segmentsFromPoints(straightRuns(arrow.points))) { + if (run.isFirstSegment || run.isLastSegment) continue; + for (const shape of nodeShapes) { + if (!keepsClearOf(shape, ends) || !segmentAlongside(run, shape, alongside)) continue; + const frame = groups.frames.find( + (candidate) => candidate.id === groups.groupOf.get(shape.nodeId), + ); + if (!frame) continue; + const [low, size, start, end] = + run.orientation === "horizontal" + ? [shape.y, shape.height, frame.box.y + ZONE_HEADER, frame.box.y + frame.box.height] + : [shape.x, shape.width, frame.box.x, frame.box.x + frame.box.width]; + const lane = + run.staticCoordinate < low ? low - alongside.gap - 1 : low + size + alongside.gap + 1; + if ( + run.staticCoordinate > start && + run.staticCoordinate < end && + (lane < start + GROUP_OUTLINE_CLEARANCE || lane > end - GROUP_OUTLINE_CLEARANCE) + ) + roomless.add(frame.id); + } + } + } + return roomless; +} + /** * How short each gap between consecutive ranks of a column, or columns of * lanes, can draw: one that lines only cross straight, COMPACT_GAP; one that @@ -7780,12 +7907,12 @@ function layoutDiagram( polish: RoutingPolish, arrangement: Arrangement = PLAIN_ARRANGEMENT, ): DiagramLayout { - let layout = layoutDiagramOnce(diagram, polish, arrangement, new Map()); + let slack = new Map(arrangement.zoneSlack ?? []); + let layout = layoutDiagramOnce(diagram, polish, arrangement, slack); if (!layout.groups) return layout; const defects = (attempt: DiagramLayout) => exportRouteDefects(attempt.edgeArrows, attempt.nodeShapes, attempt.groups).length + crowdedGroups(attempt).size; - let slack = new Map(); let score = defects(layout); for (let round = 0; round < GROUP_SLACK_ROUNDS && score > 0; round += 1) { const crowded = crowdedGroups(layout); @@ -8314,7 +8441,9 @@ function routesNoWorse( * or labels touching anything. Runs only after every other choice, so no * other route moves. In the `last` pass, after every choice, a line moves * again from where its last move left it until no run is near, and no move - * may run a line through a head: no head repair follows. + * may run a line through a head: no head repair follows. Then, in zones, each + * run alongside a node nearer than the corridor gap moves out to the gap where + * that brings its line within the clearance of no node (alongsideReach). */ function clearOutlines(layout: DiagramLayout, last = false): DiagramLayout { const { groups, lanes, nodeShapes, routedEdges } = layout; @@ -8330,31 +8459,53 @@ function clearOutlines(layout: DiagramLayout, last = false): DiagramLayout { .touches, }); let current: ReturnType | undefined; + // Runs within the clearance first, as everywhere; then, last, in zones, runs + // alongside a node, each move bringing the line within the clearance of none. + const passes = [undefined, last ? alongsideReach(layout) : undefined]; for (const [index, arrow] of layout.edgeArrows.entries()) { const ends = new Set([arrow.sourceNodeId, arrow.targetNodeId]); - let near = routeClearanceCount(arrow.points, nodeShapes, ends); - // Each move clears at least one run, so a line stops once none is near. - for (let moves = 0; near > 0 && (last || moves === 0); moves += 1) { - current ??= score(arrows); - let moved = false; - for (const points of runsClearOfOutlines(arrows[index]!.points, nodeShapes, ends)) { - const nearer = routeClearanceCount(points, nodeShapes, ends); - if (!orthogonalRoute(points) || nearer >= near) continue; - const candidate = arrows.map((other, at) => (at === index ? { ...other, points } : other)); - const next = score(candidate); - if ( - next.defects <= current.defects && - next.crossings <= current.crossings && - next.findings <= current.findings && - next.heads <= current.heads && - next.inward <= current.inward && - next.labels <= current.labels - ) { - [arrows, current, near, moved] = [candidate, next, nearer, true]; - break; + const close = (points: readonly ScenePoint[]) => routeClearanceCount(points, nodeShapes, ends); + for (const [pass, alongside] of passes.entries()) { + if (pass > 0 && !alongside) break; + const count = (points: readonly ScenePoint[]) => + alongside ? routeAlongsideCount(points, nodeShapes, ends, alongside) : close(points); + let near = count(arrows[index]!.points); + // Each move clears at least one run, so a line stops once none is near. + for (let moves = 0; near > 0 && (last || moves === 0); moves += 1) { + current ??= score(arrows); + let moved = false; + const before = close(arrows[index]!.points); + for (const points of runsClearOfOutlines( + arrows[index]!.points, + nodeShapes, + ends, + alongside, + )) { + const nearer = count(points); + if ( + !orthogonalRoute(points) || + nearer >= near || + (alongside !== undefined && close(points) > before) + ) + continue; + const candidate = arrows.map((other, at) => + at === index ? { ...other, points } : other, + ); + const next = score(candidate); + if ( + next.defects <= current.defects && + next.crossings <= current.crossings && + next.findings <= current.findings && + next.heads <= current.heads && + next.inward <= current.inward && + next.labels <= current.labels + ) { + [arrows, current, near, moved] = [candidate, next, nearer, true]; + break; + } } + if (!moved) break; } - if (!moved) break; } } return arrows === layout.edgeArrows ? layout : { ...layout, edgeArrows: arrows }; @@ -10971,6 +11122,27 @@ export function renderGraphDiagram( ) best = { arrangement, defects, layout }; } + // A line alongside a node in a zone, nearer than the corridor gap, moves out + // to the gap last (clearOutlines). Where the zone's outline leaves it no lane + // there, the zone grows, once every other choice is made, where that routes + // no worse and leaves fewer such zones. + const roomless = + orthogonal && best.layout.groups?.zoned + ? zonesWithoutAlongsideRoom(clearedLast(finished(best.layout))) + : new Set(); + if (roomless.size > 0) { + const zoneSlack = new Map(best.arrangement.zoneSlack ?? []); + for (const id of roomless) zoneSlack.set(id, (zoneSlack.get(id) ?? 0) + GROUP_SLACK); + const arrangement = { ...best.arrangement, zoneSlack }; + const layout = layoutDiagram(diagram, levels[0]!, arrangement); + const defects = defectsOf(layout); + if ( + !fewer(best.defects, defects) && + routesNoWorse(layout, best.layout, false, false) && + zonesWithoutAlongsideRoom(clearedLast(finished(layout))).size < roomless.size + ) + best = { arrangement, defects, layout }; + } // In lanes a step takes lines in and sends them out by different sides, when // that routes no worse. if (arranged && orthogonal && laned(diagram)) diff --git a/packages/diagram/renderer/src/zones.test.ts b/packages/diagram/renderer/src/zones.test.ts index 04bfae93..b84aca85 100644 --- a/packages/diagram/renderer/src/zones.test.ts +++ b/packages/diagram/renderer/src/zones.test.ts @@ -3,6 +3,7 @@ import { CANVAS_SHAPE_CURVE, bypassingHybridFlowcharts, corridorHybridFlowchart, + describedArchitectureFlowchart, describedHybridFlowchart, canvasBoundTextBox, canvasShapeOutline, @@ -10,9 +11,13 @@ import { canvasNodeIconBox, getCanvasValidationIssues, groupFixtures, + leftToRightHybridFlowchart, parseFlowchartDiagram, + queriedArchitectureFlowchart, requestedArchitectureFlowchart, routingJogReach, + routingCorridorGap, + routingCorridorRun, routingQualityScale, routingWideBend, stackedHybridFlowchart, @@ -58,6 +63,26 @@ const zonesOf = (scene: RenderedDiagramScene) => const nodeOf = (scene: RenderedDiagramScene, id: string) => scene.elements.find((e): e is NodeSceneElement => e.type === "node" && e.nodeId === id)!; +// Width over height, either way up, of what the export draws: zones, nodes and lines. +const drawnAspect = (scene: RenderedDiagramScene) => { + const points = scene.elements.flatMap((element) => + element.type === "arrow" + ? element.points + : element.type === "node" + ? [ + { x: element.x, y: element.y }, + { x: element.x + element.width, y: element.y + element.height }, + ] + : [], + ); + const [xs, ys] = [points.map((point) => point.x), points.map((point) => point.y)]; + const [width, height] = [ + Math.max(...xs) - Math.min(...xs) + 40, + Math.max(...ys) - Math.min(...ys) + 40, + ]; + return Math.max(width, height) / Math.min(width, height); +}; + describe.each(directions)("zoned flowcharts %s", (direction) => { it.each(Object.keys(fixtures))("draws %s with every zone nested, apart and clear", (name) => { const fixture = fixtures[name]!; @@ -135,36 +160,16 @@ describe("zone layout", () => { const [edge, frontend, backend] = ["edge", "frontend", "backend"].map((id) => zone(k8s, id)); expect(edge!.y + edge!.height).toBeLessThan(frontend!.y); expect(frontend!.y + frontend!.height).toBeLessThan(backend!.y); - // Left to right, the region's steps follow the flow; the data center and the - // cloud, too long for one row, wrap into two read the same way. + // Left to right, the cloud stands beside the data center that feeds it; too + // long for one row, its region wraps inside it, each row read left to right. const hybrid = rendered("hybrid-cloud"); - expect(zone(hybrid, "onprem").y + zone(hybrid, "onprem").height).toBeLessThan( - zone(hybrid, "aws").y, + expect(zone(hybrid, "onprem").x + zone(hybrid, "onprem").width).toBeLessThan( + zone(hybrid, "aws").x, ); - const [queue, sync] = [nodeOf(hybrid, "orders"), nodeOf(hybrid, "sync")]; - expect(queue.x + queue.width).toBeLessThan(sync.x); + const [vpn, api] = [nodeOf(hybrid, "vpn"), nodeOf(hybrid, "api")]; + expect(vpn.x + vpn.width).toBeLessThan(api.x); }); - // Width over height, either way up, of what the export draws: zones, nodes and lines. - const drawnAspect = (scene: RenderedDiagramScene) => { - const points = scene.elements.flatMap((element) => - element.type === "arrow" - ? element.points - : element.type === "node" - ? [ - { x: element.x, y: element.y }, - { x: element.x + element.width, y: element.y + element.height }, - ] - : [], - ); - const [xs, ys] = [points.map((point) => point.x), points.map((point) => point.y)]; - const [width, height] = [ - Math.max(...xs) - Math.min(...xs) + 40, - Math.max(...ys) - Math.min(...ys) + 40, - ]; - return Math.max(width, height) / Math.min(width, height); - }; - // Each ran 3.4:1 to 8.1:1 in one row or column; the stress case wraps least. it.each([ ["aws-three-tier", "LR", 2.45], @@ -404,6 +409,28 @@ describe("lines into a hanging zone band", () => { expect(dc.y).toBeLessThan(aws.y + aws.height); }); + it.each(["LR", "RL"] as const)( + "keeps the cloud beside the data center it follows in a %s row", + (direction) => { + // Two of three Sol samples asked for left to right hung the cloud under the + // data center: a top-level wrap came within the target aspect in two bands, + // the region's wrap inside the cloud in three, and the fewer bands won. The + // ERP's line ran down and back against the row to the VPN gateway. + const scene = renderIntermediateDiagram({ + ...leftToRightHybridFlowchart, + layout: { direction, edgeRouting: "orthogonal" }, + }); + const zones = new Map(zonesOf(scene).map((zone) => [zone.nodeId, zone])); + const [dc, aws] = [zones.get("group:dc")!, zones.get("group:aws")!]; + const forward = direction === "LR" ? 1 : -1; + const [erp, vpn] = [nodeOf(scene, "erp"), nodeOf(scene, "vpn")]; + if (direction === "LR") expect(aws.x).toBeGreaterThan(dc.x + dc.width); + else expect(dc.x).toBeGreaterThan(aws.x + aws.width); + expect(forward * (vpn.x - erp.x)).toBeGreaterThan(0); + expect(drawnAspect(scene)).toBeLessThan(2.45); + }, + ); + it("keeps the data center beside the cloud when a side change clears its region's wrap of crossings", () => { // A Sol sample stacked the cloud under the data center: beside it, its // region's wrap crossed the sync function's lines to the table and the @@ -490,6 +517,83 @@ describe("lines past an infrastructure shape", () => { expect(close).toEqual([]); }, ); + + // Every zone fixture and generated sample, in every direction. The stress + // cases stay out: thirty services pass a flowchart's limits, and the + // four-deep zones run too long for a zone to grow. + const samples: Record = { + ...zoneFixtures, + ...bypassingHybridFlowcharts, + "hybrid-cloud-archive-corridor": corridorHybridFlowchart, + "hybrid-cloud-described": describedHybridFlowchart, + "hybrid-cloud-left-to-right": leftToRightHybridFlowchart, + "hybrid-cloud-stacked": stackedHybridFlowchart, + "hybrid-cloud-sync": syncedHybridFlowchart, + "aws-three-tier-described": describedArchitectureFlowchart, + "aws-three-tier-queries": queriedArchitectureFlowchart, + "aws-three-tier-requests": requestedArchitectureFlowchart, + }; + it.each(Object.keys(samples))( + "runs no line of %s alongside a node it passes, nearer than two lines read apart", + (name) => { + // Sol and Sonnet hybrid samples ran the ERP's line to the VPN gateway 13 + // units under the Oracle database's foot, between it and the data + // center's outline, which reads as touching it at the reader's scale. + const along: string[] = []; + for (const direction of directions) { + const scene = renderIntermediateDiagram({ + ...samples[name]!, + layout: { direction, edgeRouting: "orthogonal" }, + }); + const nodes = scene.elements.filter( + (e): e is NodeSceneElement => e.type === "node" && e.rendererRole === undefined, + ); + const points = scene.elements.flatMap((e) => + e.type === "arrow" + ? e.points + : e.type === "node" + ? [ + { x: e.x, y: e.y }, + { x: e.x + e.width, y: e.y + e.height }, + ] + : [], + ); + const [xs, ys] = [points.map((point) => point.x), points.map((point) => point.y)]; + const scale = routingQualityScale({ + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }); + const [corridor, run] = [routingCorridorGap(scale), routingCorridorRun(scale)]; + for (const arrow of scene.elements) { + if (arrow.type !== "arrow") continue; + for (const [index, to] of arrow.points.slice(1).entries()) { + const from = arrow.points[index]!; + const horizontal = Math.abs(from.y - to.y) < 0.01; + for (const node of nodes) { + if (node.nodeId === arrow.sourceNodeId || node.nodeId === arrow.targetNodeId) + continue; + const [at, low, high, start, end] = horizontal + ? [from.y, node.y, node.y + node.height, node.x, node.x + node.width] + : [from.x, node.x, node.x + node.width, node.y, node.y + node.height]; + const [min, max] = horizontal + ? [Math.min(from.x, to.x), Math.max(from.x, to.x)] + : [Math.min(from.y, to.y), Math.max(from.y, to.y)]; + const distance = at < low ? low - at : at - high; + if ( + distance > 0 && + distance < corridor && + Math.min(max, end) - Math.max(min, start) > run + ) + along.push( + `${direction} ${arrow.edgeId} ${distance.toFixed(1)} from ${node.nodeId}`, + ); + } + } + } + } + expect(along).toEqual([]); + }, + ); }); describe("line ends from generated samples", () => { diff --git a/packages/diagram/ui/src/diagram-types/flowchart.stories.tsx b/packages/diagram/ui/src/diagram-types/flowchart.stories.tsx index 59b3b6a2..02d1145d 100644 --- a/packages/diagram/ui/src/diagram-types/flowchart.stories.tsx +++ b/packages/diagram/ui/src/diagram-types/flowchart.stories.tsx @@ -5,6 +5,7 @@ import { flowchartFixture, groupFixtures, laneFixtures, + leftToRightHybridFlowchart, pharmaBatchDispositionFlowchart, routingFixtures, zoneFixtures, @@ -80,6 +81,11 @@ export const ZonesHybridCloud: Story = { args: { diagram: zoneFixtures["hybrid-cloud"] }, }; +/** Asked for left to right: the cloud stands beside the data center and its region wraps inside it. */ +export const ZonesHybridCloudLeftToRight: Story = { + args: { diagram: leftToRightHybridFlowchart }, +}; + export const RoutingContentModeration: Story = { args: { diagram: routingFixtures["content-moderation"] }, };