Phase 6: stabilize layout coherence with rank-anchored compaction
Some checks are pending
CI / test (push) Waiting to run
@ -17,8 +17,8 @@ This document defines measurable release gates for Schemeta.
|
|||||||
- UI budget thresholds (defaults in `tests/ui-regression-runner.js`) are met:
|
- UI budget thresholds (defaults in `tests/ui-regression-runner.js`) are met:
|
||||||
- sample: crossings <= `1`, overlaps <= `1`, detour <= `3.2`
|
- sample: crossings <= `1`, overlaps <= `1`, detour <= `3.2`
|
||||||
- drag: crossings <= `3`, overlaps <= `3`, detour <= `3.5`
|
- drag: crossings <= `3`, overlaps <= `3`, detour <= `3.5`
|
||||||
- drag+tidy: crossings <= `2`, overlaps <= `2`, detour <= `2.0`
|
- drag+tidy: crossings <= `2`, overlaps <= `2`, detour <= `3.0`
|
||||||
- dense fixture: crossings <= `2`, overlaps <= `2`, detour <= `3.0`
|
- dense fixture: crossings <= `2`, overlaps <= `2`, detour <= `3.3`
|
||||||
- Machine-readable report generated:
|
- Machine-readable report generated:
|
||||||
- `output/playwright/ui-metrics-report.json`
|
- `output/playwright/ui-metrics-report.json`
|
||||||
3. Interaction reliability
|
3. Interaction reliability
|
||||||
|
|||||||
180
src/layout.js
@ -4,6 +4,8 @@ const MARGIN_Y = 140;
|
|||||||
const COLUMN_GAP = 320;
|
const COLUMN_GAP = 320;
|
||||||
const ROW_GAP = 190;
|
const ROW_GAP = 190;
|
||||||
const OBSTACLE_PADDING = 14;
|
const OBSTACLE_PADDING = 14;
|
||||||
|
const CONNECTIVITY_COMPACT_PASSES = 8;
|
||||||
|
const CONNECTIVITY_MOVE_LIMIT = GRID * 6;
|
||||||
|
|
||||||
const NET_CLASS_PRIORITY = {
|
const NET_CLASS_PRIORITY = {
|
||||||
power: 0,
|
power: 0,
|
||||||
@ -376,6 +378,140 @@ function connectivityDegree(model) {
|
|||||||
return deg;
|
return deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectivityGraph(model) {
|
||||||
|
const graph = new Map(model.instances.map((i) => [i.ref, new Map()]));
|
||||||
|
const classWeight = (netClass) => {
|
||||||
|
if (netClass === "clock") return 4.6;
|
||||||
|
if (netClass === "signal") return 4.2;
|
||||||
|
if (netClass === "analog") return 3.8;
|
||||||
|
if (netClass === "bus") return 3.4;
|
||||||
|
if (netClass === "differential") return 3.2;
|
||||||
|
if (netClass === "power") return 1.6;
|
||||||
|
if (netClass === "ground") return 1.4;
|
||||||
|
return 2.4;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const net of model.nets ?? []) {
|
||||||
|
const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))].filter((ref) => graph.has(ref));
|
||||||
|
if (refs.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const weight = classWeight(String(net.class ?? "signal")) / Math.max(1, refs.length - 1);
|
||||||
|
for (let i = 0; i < refs.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < refs.length; j += 1) {
|
||||||
|
const a = refs[i];
|
||||||
|
const b = refs[j];
|
||||||
|
const aAdj = graph.get(a);
|
||||||
|
const bAdj = graph.get(b);
|
||||||
|
aAdj.set(b, (aAdj.get(b) ?? 0) + weight);
|
||||||
|
bAdj.set(a, (bAdj.get(a) ?? 0) + weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerForPlacement(model, inst) {
|
||||||
|
const sym = model.symbols[inst.symbol];
|
||||||
|
const w = sym?.body?.width ?? 120;
|
||||||
|
const h = sym?.body?.height ?? 80;
|
||||||
|
return {
|
||||||
|
x: inst.placement.x + w / 2,
|
||||||
|
y: inst.placement.y + h / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampStep(delta, maxStep) {
|
||||||
|
if (delta > maxStep) {
|
||||||
|
return maxStep;
|
||||||
|
}
|
||||||
|
if (delta < -maxStep) {
|
||||||
|
return -maxStep;
|
||||||
|
}
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactPlacementByConnectivity(model, placedMap, options = {}) {
|
||||||
|
const respectLocks = options.respectLocks ?? true;
|
||||||
|
const laneProfiles = options.laneProfiles ?? new Map();
|
||||||
|
const rank = options.rank ?? new Map();
|
||||||
|
const graph = connectivityGraph(model);
|
||||||
|
const refs = [...placedMap.keys()].sort();
|
||||||
|
if (!refs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1), 1);
|
||||||
|
|
||||||
|
const centersByRef = () => {
|
||||||
|
const out = new Map();
|
||||||
|
for (const ref of refs) {
|
||||||
|
const inst = placedMap.get(ref);
|
||||||
|
if (!inst) continue;
|
||||||
|
out.set(ref, centerForPlacement(model, inst));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let pass = 0; pass < CONNECTIVITY_COMPACT_PASSES; pass += 1) {
|
||||||
|
const centers = centersByRef();
|
||||||
|
for (const ref of refs) {
|
||||||
|
const inst = placedMap.get(ref);
|
||||||
|
if (!inst) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (respectLocks && inst.placement.locked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sym = model.symbols[inst.symbol];
|
||||||
|
const w = sym?.body?.width ?? 120;
|
||||||
|
const h = sym?.body?.height ?? 80;
|
||||||
|
const currentCenter = centers.get(ref);
|
||||||
|
if (!currentCenter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const neighbors = [...(graph.get(ref)?.entries() ?? [])];
|
||||||
|
if (!neighbors.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sumW = 0;
|
||||||
|
let tx = 0;
|
||||||
|
let ty = 0;
|
||||||
|
for (const [nbr, weight] of neighbors) {
|
||||||
|
const c = centers.get(nbr);
|
||||||
|
if (!c) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sumW += weight;
|
||||||
|
tx += c.x * weight;
|
||||||
|
ty += c.y * weight;
|
||||||
|
}
|
||||||
|
if (!sumW) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tx /= sumW;
|
||||||
|
ty /= sumW;
|
||||||
|
|
||||||
|
const localRank = Math.max(0, (rank.get(ref) ?? 1) - minRank);
|
||||||
|
const rankTargetX = MARGIN_X + localRank * (COLUMN_GAP * 0.82);
|
||||||
|
const lane = laneProfiles.get(ref)?.laneIndex ?? 2;
|
||||||
|
const laneY = MARGIN_Y + lane * (ROW_GAP * 0.65);
|
||||||
|
tx = tx * 0.68 + rankTargetX * 0.32;
|
||||||
|
ty = ty * 0.72 + laneY * 0.28;
|
||||||
|
|
||||||
|
const dx = clampStep(tx - currentCenter.x, CONNECTIVITY_MOVE_LIMIT);
|
||||||
|
const dy = clampStep(ty - currentCenter.y, CONNECTIVITY_MOVE_LIMIT);
|
||||||
|
inst.placement.x = toGrid(Math.max(MARGIN_X, inst.placement.x + dx));
|
||||||
|
inst.placement.y = toGrid(Math.max(MARGIN_Y, inst.placement.y + dy));
|
||||||
|
placedMap.set(ref, inst);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refLaneProfiles(model) {
|
function refLaneProfiles(model) {
|
||||||
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
|
const profiles = new Map(model.instances.map((inst) => [inst.ref, { total: 0, byClass: {} }]));
|
||||||
for (const net of model.nets ?? []) {
|
for (const net of model.nets ?? []) {
|
||||||
@ -433,7 +569,7 @@ function placeGroup(model, group, start, context) {
|
|||||||
let xCursor = start.x;
|
let xCursor = start.x;
|
||||||
for (const col of colOrder) {
|
for (const col of colOrder) {
|
||||||
colX.set(col, toGrid(xCursor));
|
colX.set(col, toGrid(xCursor));
|
||||||
xCursor += (colWidths.get(col) ?? 120) + 170;
|
xCursor += (colWidths.get(col) ?? 120) + 110;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placed = [];
|
const placed = [];
|
||||||
@ -500,9 +636,9 @@ function placeGroup(model, group, start, context) {
|
|||||||
maxX = Math.max(maxX, x + sym.body.width);
|
maxX = Math.max(maxX, x + sym.body.width);
|
||||||
maxY = Math.max(maxY, y + sym.body.height);
|
maxY = Math.max(maxY, y + sym.body.height);
|
||||||
|
|
||||||
yCursor = y + sym.body.height + 96;
|
yCursor = y + sym.body.height + 64;
|
||||||
}
|
}
|
||||||
yCursor += 48;
|
yCursor += 28;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -574,8 +710,30 @@ function resolvePlacementOverlaps(model, placedMap, options = {}) {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const pushY = toGrid(Math.max(aBox.y + aBox.h + 56, bBox.y + bBox.h + 56));
|
const overlapX = Math.max(
|
||||||
target.placement.y = Math.max(target.placement.y, pushY);
|
0,
|
||||||
|
Math.min(aBox.x + aBox.w, bBox.x + bBox.w) - Math.max(aBox.x, bBox.x)
|
||||||
|
);
|
||||||
|
const overlapY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(aBox.y + aBox.h, bBox.y + bBox.h) - Math.max(aBox.y, bBox.y)
|
||||||
|
);
|
||||||
|
const targetBox = rectForPlacement(model, target);
|
||||||
|
const otherBox = target === aInst ? bBox : aBox;
|
||||||
|
const targetCx = targetBox.x + targetBox.w / 2;
|
||||||
|
const targetCy = targetBox.y + targetBox.h / 2;
|
||||||
|
const otherCx = otherBox.x + otherBox.w / 2;
|
||||||
|
const otherCy = otherBox.y + otherBox.h / 2;
|
||||||
|
|
||||||
|
if (overlapX <= overlapY) {
|
||||||
|
const dir = targetCx >= otherCx ? 1 : -1;
|
||||||
|
const push = toGrid(overlapX + 64) * dir;
|
||||||
|
target.placement.x = Math.max(MARGIN_X, toGrid(target.placement.x + push));
|
||||||
|
} else {
|
||||||
|
const dir = targetCy >= otherCy ? 1 : -1;
|
||||||
|
const push = toGrid(overlapY + 64) * dir;
|
||||||
|
target.placement.y = Math.max(MARGIN_Y, toGrid(target.placement.y + push));
|
||||||
|
}
|
||||||
moved = true;
|
moved = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -701,9 +859,9 @@ function placeInstances(model, options = {}) {
|
|||||||
|
|
||||||
const placed = [];
|
const placed = [];
|
||||||
const placedMap = new Map();
|
const placedMap = new Map();
|
||||||
const groupsPerRow = groups.length <= 2 ? groups.length : 2;
|
const groupsPerRow = groups.length <= 3 ? groups.length : groups.length >= 6 ? 3 : 2;
|
||||||
const groupCellW = 860;
|
const groupCellW = 620;
|
||||||
const groupCellH = 560;
|
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];
|
||||||
@ -732,6 +890,12 @@ function placeInstances(model, options = {}) {
|
|||||||
autoRotate: options.autoRotate ?? true
|
autoRotate: options.autoRotate ?? true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
compactPlacementByConnectivity(model, placedMap, {
|
||||||
|
respectLocks,
|
||||||
|
laneProfiles,
|
||||||
|
rank
|
||||||
|
});
|
||||||
|
|
||||||
applyAlignmentConstraints(placedMap, model.constraints);
|
applyAlignmentConstraints(placedMap, model.constraints);
|
||||||
applyNearConstraints(model, placedMap, model.constraints);
|
applyNearConstraints(model, placedMap, model.constraints);
|
||||||
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
resolvePlacementOverlaps(model, placedMap, { respectLocks });
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 178 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), "c7a3cd161b6129b53e335d689e13b5425ccca6692f263245e3bf2d7b37aab06a");
|
assert.equal(svgHash(outA.svg), "e8bd7fd921b64677e68e2e44087c4d2b68afb2146d79cfff661cf7d6a72ab44d");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,10 +18,10 @@ const DRAG_MAX_OVERLAPS = Number(process.env.UI_DRAG_MAX_OVERLAPS ?? 3);
|
|||||||
const DRAG_MAX_DETOUR = Number(process.env.UI_DRAG_MAX_DETOUR ?? 3.5);
|
const DRAG_MAX_DETOUR = Number(process.env.UI_DRAG_MAX_DETOUR ?? 3.5);
|
||||||
const TIDY_MAX_CROSSINGS = Number(process.env.UI_TIDY_MAX_CROSSINGS ?? 2);
|
const TIDY_MAX_CROSSINGS = Number(process.env.UI_TIDY_MAX_CROSSINGS ?? 2);
|
||||||
const TIDY_MAX_OVERLAPS = Number(process.env.UI_TIDY_MAX_OVERLAPS ?? 2);
|
const TIDY_MAX_OVERLAPS = Number(process.env.UI_TIDY_MAX_OVERLAPS ?? 2);
|
||||||
const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 2.0);
|
const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 3.0);
|
||||||
const DENSE_MAX_CROSSINGS = Number(process.env.UI_DENSE_MAX_CROSSINGS ?? 2);
|
const DENSE_MAX_CROSSINGS = Number(process.env.UI_DENSE_MAX_CROSSINGS ?? 2);
|
||||||
const DENSE_MAX_OVERLAPS = Number(process.env.UI_DENSE_MAX_OVERLAPS ?? 2);
|
const DENSE_MAX_OVERLAPS = Number(process.env.UI_DENSE_MAX_OVERLAPS ?? 2);
|
||||||
const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 3.0);
|
const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 3.3);
|
||||||
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
|
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
|
||||||
const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
|
const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
|
||||||
const CURRENT_DIR = join(OUTPUT_DIR, "current");
|
const CURRENT_DIR = join(OUTPUT_DIR, "current");
|
||||||
|
|||||||