Refine group placement seeding to reduce collapse and detour
Some checks are pending
CI / test (push) Waiting to run
132
src/layout.js
@ -1279,6 +1279,127 @@ function applyAutoRotation(model, placedMap, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function groupLaneIndex(group, laneProfiles) {
|
||||
const counts = new Map();
|
||||
for (const ref of group.members ?? []) {
|
||||
const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
|
||||
counts.set(lane, (counts.get(lane) ?? 0) + 1);
|
||||
}
|
||||
const sorted = [...counts.entries()].sort((a, b) => {
|
||||
if (a[1] !== b[1]) {
|
||||
return b[1] - a[1];
|
||||
}
|
||||
return a[0] - b[0];
|
||||
});
|
||||
return sorted[0]?.[0] ?? 2;
|
||||
}
|
||||
|
||||
function estimateGroupFootprint(group, rank, degree) {
|
||||
const refs = [...(group.members ?? [])].sort((a, b) => {
|
||||
const ra = rank.get(a) ?? 1;
|
||||
const rb = rank.get(b) ?? 1;
|
||||
if (ra !== rb) {
|
||||
return ra - rb;
|
||||
}
|
||||
const da = degree.get(a) ?? 0;
|
||||
const db = degree.get(b) ?? 0;
|
||||
if (da !== db) {
|
||||
return db - da;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const cols = rankColumnsForRefs(refs, rank);
|
||||
const colCount = Math.max(1, cols.size);
|
||||
const maxRowsInCol = Math.max(1, ...[...cols.values()].map((list) => list.length));
|
||||
const width = toGrid(240 + colCount * 220);
|
||||
const height = toGrid(180 + maxRowsInCol * 120);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function groupRectsOverlap(a, b, pad = 80) {
|
||||
return !(
|
||||
a.x + a.w + pad <= b.x ||
|
||||
b.x + b.w + pad <= a.x ||
|
||||
a.y + a.h + pad <= b.y ||
|
||||
b.y + b.h + pad <= a.y
|
||||
);
|
||||
}
|
||||
|
||||
function computeGroupOrigins(groups, rank, laneProfiles, degree) {
|
||||
if (!groups.length) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const globalMinRank = Math.min(
|
||||
...groups.flatMap((group) => (group.members ?? []).map((ref) => rank.get(ref) ?? 1)),
|
||||
0
|
||||
);
|
||||
const strideX = 420;
|
||||
const laneStrideY = 120;
|
||||
const origins = new Map();
|
||||
const usedRects = [];
|
||||
|
||||
const planned = groups
|
||||
.map((group, idx) => {
|
||||
const refs = group.members ?? [];
|
||||
const minRank = Math.min(...refs.map((ref) => rank.get(ref) ?? 1), globalMinRank);
|
||||
const lane = groupLaneIndex(group, laneProfiles);
|
||||
const laneBand = Math.max(0, Math.min(3, lane));
|
||||
const footprint = estimateGroupFootprint(group, rank, degree);
|
||||
return {
|
||||
group,
|
||||
idx,
|
||||
minRank,
|
||||
lane,
|
||||
laneBand,
|
||||
footprint,
|
||||
targetX: toGrid(MARGIN_X + Math.max(0, minRank - globalMinRank) * strideX),
|
||||
targetY: toGrid(MARGIN_Y + laneBand * laneStrideY)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.targetX !== b.targetX) {
|
||||
return a.targetX - b.targetX;
|
||||
}
|
||||
if (a.targetY !== b.targetY) {
|
||||
return a.targetY - b.targetY;
|
||||
}
|
||||
return a.group.name.localeCompare(b.group.name);
|
||||
});
|
||||
|
||||
const targetColumnSlots = new Map();
|
||||
for (const item of planned) {
|
||||
const slotKey = `${item.targetX}`;
|
||||
const slot = targetColumnSlots.get(slotKey) ?? 0;
|
||||
targetColumnSlots.set(slotKey, slot + 1);
|
||||
const fanoutStep = planned.length <= 3 ? Math.floor(strideX * 0.78) : Math.floor(strideX * 0.42);
|
||||
const fanoutX = toGrid(slot * fanoutStep);
|
||||
let x = toGrid(item.targetX + fanoutX);
|
||||
let y = item.targetY;
|
||||
let tries = 0;
|
||||
while (tries < 28) {
|
||||
const rect = { x, y, w: item.footprint.width, h: item.footprint.height };
|
||||
const collides = usedRects.some((other) => groupRectsOverlap(rect, other));
|
||||
if (!collides) {
|
||||
usedRects.push(rect);
|
||||
origins.set(item.group.name, { x, y });
|
||||
break;
|
||||
}
|
||||
y = toGrid(y + laneStrideY);
|
||||
if (tries > 0 && tries % 6 === 0) {
|
||||
x = toGrid(x + Math.floor(strideX * 0.5));
|
||||
y = item.targetY;
|
||||
}
|
||||
tries += 1;
|
||||
}
|
||||
if (!origins.has(item.group.name)) {
|
||||
origins.set(item.group.name, { x: item.targetX, y: toGrid(item.targetY + laneStrideY * 2) });
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
function placeInstances(model, options = {}) {
|
||||
const respectLocks = options.respectLocks ?? true;
|
||||
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
|
||||
@ -1290,18 +1411,11 @@ function placeInstances(model, options = {}) {
|
||||
|
||||
const placed = [];
|
||||
const placedMap = new Map();
|
||||
const groupsPerRow = groups.length <= 3 ? groups.length : groups.length >= 6 ? 3 : 2;
|
||||
const groupCellW = 620;
|
||||
const groupCellH = 420;
|
||||
const groupOrigins = computeGroupOrigins(groups, rank, laneProfiles, degree);
|
||||
|
||||
for (let i = 0; i < groups.length; i += 1) {
|
||||
const group = groups[i];
|
||||
const row = groupsPerRow ? Math.floor(i / groupsPerRow) : 0;
|
||||
const col = groupsPerRow ? i % groupsPerRow : 0;
|
||||
const origin = {
|
||||
x: toGrid(MARGIN_X + col * groupCellW),
|
||||
y: toGrid(MARGIN_Y + row * groupCellH)
|
||||
};
|
||||
const origin = groupOrigins.get(group.name) ?? { x: toGrid(MARGIN_X), y: toGrid(MARGIN_Y + i * 220) };
|
||||
|
||||
const out = placeGroup(model, group, origin, {
|
||||
rank,
|
||||
|
||||
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 192 KiB |
@ -15,5 +15,5 @@ test("render output for reference fixture remains deterministic", () => {
|
||||
assert.equal(outA.ok, true);
|
||||
assert.equal(outB.ok, true);
|
||||
assert.equal(outA.svg, outB.svg);
|
||||
assert.equal(svgHash(outA.svg), "80ab4c279caf29b2a14096346d6e993ef677f41bf1b3bc226eefa7b069b6487d");
|
||||
assert.equal(svgHash(outA.svg), "a793f82594e4aff3e898db85cd7e984e86ad568d58aecd33661e8bbb1eff1856");
|
||||
});
|
||||
|
||||