diff --git a/src/layout.js b/src/layout.js index 1b72928..ce1f83a 100644 --- a/src/layout.js +++ b/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, diff --git a/tests/baselines/ui/dense-analog.png b/tests/baselines/ui/dense-analog.png index 583d51c..425e6ae 100644 Binary files a/tests/baselines/ui/dense-analog.png and b/tests/baselines/ui/dense-analog.png differ diff --git a/tests/baselines/ui/explicit-mode-auto-tidy.png b/tests/baselines/ui/explicit-mode-auto-tidy.png index 2ebbbb9..5e2c98b 100644 Binary files a/tests/baselines/ui/explicit-mode-auto-tidy.png and b/tests/baselines/ui/explicit-mode-auto-tidy.png differ diff --git a/tests/baselines/ui/initial.png b/tests/baselines/ui/initial.png index 1f6314a..9697d7e 100644 Binary files a/tests/baselines/ui/initial.png and b/tests/baselines/ui/initial.png differ diff --git a/tests/baselines/ui/laptop-viewport.png b/tests/baselines/ui/laptop-viewport.png index bdd6edf..928457b 100644 Binary files a/tests/baselines/ui/laptop-viewport.png and b/tests/baselines/ui/laptop-viewport.png differ diff --git a/tests/baselines/ui/post-migration-apply.png b/tests/baselines/ui/post-migration-apply.png index 7db3f32..c020258 100644 Binary files a/tests/baselines/ui/post-migration-apply.png and b/tests/baselines/ui/post-migration-apply.png differ diff --git a/tests/baselines/ui/selected-u2.png b/tests/baselines/ui/selected-u2.png index 253d620..a7faa8c 100644 Binary files a/tests/baselines/ui/selected-u2.png and b/tests/baselines/ui/selected-u2.png differ diff --git a/tests/render-regression.test.js b/tests/render-regression.test.js index eb7c03a..b4f7900 100644 --- a/tests/render-regression.test.js +++ b/tests/render-regression.test.js @@ -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"); });