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 = {}) {
|
function placeInstances(model, options = {}) {
|
||||||
const respectLocks = options.respectLocks ?? true;
|
const respectLocks = options.respectLocks ?? true;
|
||||||
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
|
const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
|
||||||
@ -1290,18 +1411,11 @@ function placeInstances(model, options = {}) {
|
|||||||
|
|
||||||
const placed = [];
|
const placed = [];
|
||||||
const placedMap = new Map();
|
const placedMap = new Map();
|
||||||
const groupsPerRow = groups.length <= 3 ? groups.length : groups.length >= 6 ? 3 : 2;
|
const groupOrigins = computeGroupOrigins(groups, rank, laneProfiles, degree);
|
||||||
const groupCellW = 620;
|
|
||||||
const groupCellH = 420;
|
|
||||||
|
|
||||||
for (let i = 0; i < groups.length; i += 1) {
|
for (let i = 0; i < groups.length; i += 1) {
|
||||||
const group = groups[i];
|
const group = groups[i];
|
||||||
const row = groupsPerRow ? Math.floor(i / groupsPerRow) : 0;
|
const origin = groupOrigins.get(group.name) ?? { x: toGrid(MARGIN_X), y: toGrid(MARGIN_Y + i * 220) };
|
||||||
const col = groupsPerRow ? i % groupsPerRow : 0;
|
|
||||||
const origin = {
|
|
||||||
x: toGrid(MARGIN_X + col * groupCellW),
|
|
||||||
y: toGrid(MARGIN_Y + row * groupCellH)
|
|
||||||
};
|
|
||||||
|
|
||||||
const out = placeGroup(model, group, origin, {
|
const out = placeGroup(model, group, origin, {
|
||||||
rank,
|
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(outA.ok, true);
|
||||||
assert.equal(outB.ok, true);
|
assert.equal(outB.ok, true);
|
||||||
assert.equal(outA.svg, outB.svg);
|
assert.equal(outA.svg, outB.svg);
|
||||||
assert.equal(svgHash(outA.svg), "80ab4c279caf29b2a14096346d6e993ef677f41bf1b3bc226eefa7b069b6487d");
|
assert.equal(svgHash(outA.svg), "a793f82594e4aff3e898db85cd7e984e86ad568d58aecd33661e8bbb1eff1856");
|
||||||
});
|
});
|
||||||
|
|||||||