Skip to content

pack.bubbleLayout ​

Arrange sized bubbles so that wished-for neighbours end up near each other and nothing overlaps.

One call. Nodes carry an area (or a radius) and an optional pin; adjacencies carry a signed weight — positive pulls a pair together, negative pushes it apart. Each option is seeded from a strategy, relaxed by a fixed-count force pass, projected apart until no circle overlaps another, then scored. Options come back best first.

The module knows nothing about buildings. Room programmes, org charts, service diagrams and exhibition stands are the same call with different labels. One declared length unit, continuous weights, a real seed, caller-chosen strategies and option count, an optional site ring, and value semantics — the caller's arrays are never touched.

Pure module: no host, no DOM, no clock, no RNG. All lengths are in input.unit (areas in its square); the algorithm computes natively on the caller's numbers and only the module's own floors and tolerances are scaled to the unit. Plan coordinates are x/z; Y is up and never appears.

ts
// plugin worker
import { bubbleLayout } from "@snaptrude/plugin-toolkit/pack-bubble-layout";

// agent run_worker
modules.pack.bubbleLayout.bubbleLayout(...)

Every input carries unit ("mm" | "cm" | "m" | "ft" | "in"). All lengths in that call — input and output — are in it; angles are degrees, areas are that unit squared.

When to use it ​

Use it forNot for
Areas plus adjacency wishes, with no boundary needed: room programmes, org charts, service diagrams, exhibition standsRectangles that tile with no leftover space — see pack.rooms
Pinning an entrance or a core and relaxing everything else around itCorridors, walls, or node sizes that adapt to a footprint
Seeding a downstream packer with centresA layout that stays stable across small edits, or more than ~300 nodes

Inputs and outputs ​

The shapes every function below speaks. Each appears once; a field's note says what changing it does to the result.

BubbleLayoutInput ​

  • unit: LengthUnit — The unit every length in this input is in; areas are in its square. Echoed on the output.
  • nodes: BubbleNode[]
  • adjacencies?: BubbleAdjacency[] — Sparse list of pair wishes. Absent or empty is fine — you get a compact blob.
  • boundary?: BubbleBoundary — Ring the bubbles should stay inside. Containment is applied before the final separation pass, so overlap-free wins over the boundary when the ring is too tight; nodes that could not fit are named in notes.
  • padding?: number — Clearance kept between every pair on top of rA + rB, in unit. Bigger padding gives a looser, larger diagram. Default 0.
  • iterations?: number — Force-relax steps. No convergence test: the count is the budget. Fewer is faster and less settled; the layout is overlap-free either way. Cost is iterations · n². Default 260.
  • seed?: number — Jitter stream. Same seed, same output, byte for byte. A different seed moves every seeded start slightly and so lands in a different local minimum. Default 1.
  • optionCount?: number — How many options to return. Options beyond strategies.length repeat the strategies with a derived seed, so they are different layouts, not copies. Default is strategies.length (3).
  • strategies?: BubbleStrategy[] — Which seed strategies to try, in order. Default all three.

BubbleNode ​

One bubble: a room, a department, a team, a stand.

  • id: string — Caller's handle. Must be unique; used for pairing and for every tie-break.
  • label?: string — Human name. Used for tie-breaks (label, then id) and nothing else. Renaming a node can reorder equal-degree seeds, and so change a layout. Ids alone are enough if you want labels to be free.
  • area?: number — Floor area, in unit². Converted to a radius as sqrt(area / π). Bigger nodes claim more room and push harder. Ignored when radius is given.
  • radius?: number — Radius in unit, used directly. Wins over area.
  • fixed?: PlanPoint — Pin, in unit. The node sits exactly here in every option. Frozen through seed, relax, separation and containment — everything else moves around it. Two pins placed closer than rA + rB + padding stay overlapped; the option reports overlapFree: false and a note names the pair.
  • group?: string — Grouping tag (department, discipline, sponsor tier). Same-group pairs with no adjacency of their own get a mild pull together, and "grid-seeded" seeds a group into neighbouring cells.

BubbleAdjacency ​

One wish about a pair.

  • a: string
  • b: string
  • weight?: number — Signed strength. 2 pulls to touching, 1 to a comfortable walk, -1 pushes apart; anything between interpolates. Wins over type. Absent and with no type means 1. Magnitudes above 2 (or below −2) clamp.
  • type?: BubbleAdjacencyType — Shorthand for weight: adjacent = 2, near = 1, apart = −1.

BubbleBoundary ​

Site outline the bubbles should stay inside, in unit. Closed automatically; winding does not matter.

  • pts: PlanPoint[]

BubbleLayoutOutput ​

  • contractVersion: typeof BUBBLE_LAYOUT_CONTRACT_VERSION
  • unit: LengthUnit — The unit every length below is in — the same value the input declared.
  • options: BubbleLayoutOption[] — Ranked best first by score.overall. Length is optionCount, or 0 when there are no nodes.
  • notes: string[] — Anything the caller should know: dropped duplicates, floored radii, nodes that would not fit.

BubbleLayoutOption ​

  • id: string — o1, o2, … best first.
  • strategy: BubbleStrategy
  • seed: number — The seed this option actually ran with (seed plus its index).
  • nodes: BubblePlacement[]
  • score: BubbleScore
  • edges: BubbleEdgeResult[] — One entry per wish, input order. Filter on satisfied: false to explain a score.

BubblePlacement ​

Where one bubble ended up, in unit.

  • id: string
  • centre: PlanPoint
  • radius: number

BubbleScore ​

  • satisfied: number — Wishes met.
  • total: number — Wishes given (deduplicated pairs).
  • overall: number — 0–100, satisfied-weighted adjacency and compactness combined. Ranks the options.
  • adjacency: number — 0–100 adjacency term: a distance falloff per wish, weighted by |weight|.
  • compactness: number — 0–100: bubble area over bounding-box area. High means a tight diagram.
  • overlapFree: boolean — True in every option unless two pinned nodes were handed in overlapping.

BubbleEdgeResult ​

How one wish fared.

  • a: string
  • b: string
  • weight: number — The effective signed weight, after type and clamping.
  • satisfied: boolean — True when the pair is within (attract) or beyond (apart) its target distance.
  • distance: number — Centre-to-centre distance in the finished layout, in unit.
  • target: number — The distance the pair was judged against, in unit.

Functions ​

bubbleLayout(input) ​

Lay sized bubbles out so that wished-for neighbours end up near each other and no circle overlaps another. Returns optionCount whole-layout options, best first. Every length is read and written in input.unit.

  • Parameters:
  • Returns: BubbleLayoutOutput
  • Throws: InvalidUnitError — when input.unit is missing or unknown.
  • Throws: InvalidBubbleInputError — on a duplicate node id, a node with no size, or an adjacency naming an unknown node.

Guarantees: no two circles in a returned option overlap (unless two pinned nodes were handed in overlapping, which score.overlapFree reports); pinned nodes come back exactly where they were put; the same input and seed give byte-identical output; options are sorted by score.overall.

Examples ​

A brief and its adjacency wishes into three ranked options (both)

ts
// 1800 mm clearance between every pair; the nodes carry `area` in mm².
return bubbleLayout({
  unit: "mm",
  nodes,
  adjacencies,
  padding: 1800,
  seed: 1
});

Pick on a criterion the scorer does not have — north-facing daylight (agent)

ts
const wanted = new Set(wantsDaylight);
const { options } = bubbleLayout({
  unit: "mm",
  nodes,
  adjacencies,
  padding: 1800,
  optionCount: 6
});
const daylight = (o: BubbleLayoutOption) =>
  o.nodes.filter((p) => wanted.has(p.id) && p.centre.z < 0).length;
return options.reduce((a, b) => (daylight(b) > daylight(a) ? b : a));

Pin the entrance and the core, relax everything else around them (both)

ts
// Pins are plan points in the same unit as the brief — mm here.
const pinned = nodes.map((n) => (pins[n.id] ? { ...n, fixed: pins[n.id] } : n));
return bubbleLayout({ unit: "mm", nodes: pinned, adjacencies, padding: 1800 });

Keep the diagram inside the site outline (both)

ts
return bubbleLayout({
  unit: "mm",
  nodes,
  adjacencies,
  boundary: { pts: siteRing },
  padding: 1200,
  strategies: ["grid-seeded", "adjacency-first"]
});

Errors ​

ConditionResult
bubbleLayout() — input.unit is missing or unknown.throws InvalidUnitError
bubbleLayout() — a duplicate node id, a node with no size, or an adjacency naming an unknown node.throws InvalidBubbleInputError
two nodes with the same id throw InvalidBubbleInputError rather than silently pairingpinned by duplicate-ids-error