mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(check): elongated pivot drift + counterfactual connector_detached (#2819)
This commit is contained in:
@@ -1198,6 +1198,7 @@
|
||||
return issues;
|
||||
}
|
||||
|
||||
// Soft prior only — the counterfactual attach test (below) is what makes detachment a finding.
|
||||
const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
|
||||
const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
|
||||
|
||||
@@ -1207,14 +1208,16 @@
|
||||
return `${element.id || ""} ${className}`;
|
||||
}
|
||||
|
||||
// Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms.
|
||||
function pathScreenEndpoints(svg, path) {
|
||||
if (
|
||||
typeof path.getTotalLength !== "function" ||
|
||||
typeof path.getPointAtLength !== "function" ||
|
||||
typeof path.getScreenCTM !== "function" ||
|
||||
typeof svg.createSVGPoint !== "function"
|
||||
) {
|
||||
function isConnectorPath(svg, path) {
|
||||
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
|
||||
return (
|
||||
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
|
||||
);
|
||||
}
|
||||
|
||||
/** Raw `d`-space endpoints (no CTM) — the mapping authors use when they paste screen coords into `d`. */
|
||||
function pathUserEndpoints(path) {
|
||||
if (typeof path.getTotalLength !== "function" || typeof path.getPointAtLength !== "function") {
|
||||
return null;
|
||||
}
|
||||
let total;
|
||||
@@ -1224,6 +1227,20 @@
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(total) || total <= 0) return null;
|
||||
const start = path.getPointAtLength(0);
|
||||
const end = path.getPointAtLength(total);
|
||||
return { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y } };
|
||||
}
|
||||
|
||||
// Screen endpoints via getScreenCTM (viewBox, preserveAspectRatio, group transforms).
|
||||
function pathScreenEndpoints(svg, path, user) {
|
||||
if (
|
||||
!user ||
|
||||
typeof path.getScreenCTM !== "function" ||
|
||||
typeof svg.createSVGPoint !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const matrix = path.getScreenCTM();
|
||||
if (!matrix) return null;
|
||||
const toScreen = (local) => {
|
||||
@@ -1233,10 +1250,7 @@
|
||||
const mapped = point.matrixTransform(matrix);
|
||||
return { x: mapped.x, y: mapped.y };
|
||||
};
|
||||
return {
|
||||
start: toScreen(path.getPointAtLength(0)),
|
||||
end: toScreen(path.getPointAtLength(total)),
|
||||
};
|
||||
return { start: toScreen(user.start), end: toScreen(user.end) };
|
||||
}
|
||||
|
||||
function distanceToRect(point, rect) {
|
||||
@@ -1246,6 +1260,7 @@
|
||||
}
|
||||
|
||||
// Solid, compact elements a connector could plausibly anchor to.
|
||||
// Both tiers keep `element` so attachment identity is stable across containment vs near-miss.
|
||||
function connectorAnchorRects(root, rootRect) {
|
||||
const compact = [];
|
||||
const painted = [];
|
||||
@@ -1261,42 +1276,57 @@
|
||||
if (area < 400) continue;
|
||||
// Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
|
||||
if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
|
||||
if (area <= rootArea * 0.15) compact.push(rect);
|
||||
if (area <= rootArea * 0.15) compact.push({ rect, element });
|
||||
}
|
||||
return { compact, painted };
|
||||
}
|
||||
|
||||
function isConnectorPath(svg, path) {
|
||||
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
|
||||
return (
|
||||
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
|
||||
);
|
||||
}
|
||||
|
||||
// A connector whose BOTH endpoints land far from every anchorable element was drawn in the wrong frame.
|
||||
// min over the two endpoints is intentional: a half-attached connector is a design choice, not frame drift.
|
||||
// Flag only the documented bug: rendered endpoints miss, but user-space-as-screen would attach.
|
||||
function connectorDetachmentIssues(root, rootRect, time) {
|
||||
const issues = [];
|
||||
let anchors = null;
|
||||
// Attach near-miss tolerance (screen px). Separate from the closed-glyph chord floor.
|
||||
const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
|
||||
const MIN_CONNECTOR_CHORD_PX = 8;
|
||||
for (const svg of Array.from(root.querySelectorAll("svg"))) {
|
||||
if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
|
||||
for (const path of Array.from(svg.querySelectorAll("path"))) {
|
||||
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
|
||||
if (!isConnectorPath(svg, path)) continue;
|
||||
const endpoints = pathScreenEndpoints(svg, path);
|
||||
if (!endpoints) continue;
|
||||
const user = pathUserEndpoints(path);
|
||||
const rendered = pathScreenEndpoints(svg, path, user);
|
||||
if (!user || !rendered) continue;
|
||||
// Closed/glyph paths collapse to one point — compare in screen px (not user units).
|
||||
const renderedChord = Math.hypot(
|
||||
rendered.end.x - rendered.start.x,
|
||||
rendered.end.y - rendered.start.y,
|
||||
);
|
||||
if (renderedChord < MIN_CONNECTOR_CHORD_PX) continue;
|
||||
if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
|
||||
if (anchors.compact.length < 2) return issues;
|
||||
const attached = (point) =>
|
||||
anchors.painted.some(
|
||||
(anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0,
|
||||
) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold);
|
||||
if (attached(endpoints.start) || attached(endpoints.end)) continue;
|
||||
// Stable DOM identity across painted (inside) and compact (near-miss) tiers.
|
||||
const attachmentKey = (point) => {
|
||||
for (const anchor of anchors.painted) {
|
||||
if (!anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0) {
|
||||
return anchor.element;
|
||||
}
|
||||
}
|
||||
for (const anchor of anchors.compact) {
|
||||
if (distanceToRect(point, anchor.rect) <= threshold) return anchor.element;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const attached = (point) => attachmentKey(point) !== null;
|
||||
// Half-attached as drawn is allowed; only full render-miss proceeds.
|
||||
if (attached(rendered.start) || attached(rendered.end)) continue;
|
||||
// Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels.
|
||||
const userStartKey = attachmentKey(user.start);
|
||||
const userEndKey = attachmentKey(user.end);
|
||||
if (!userStartKey || !userEndKey || userStartKey === userEndKey) continue;
|
||||
const gap = Math.round(
|
||||
Math.min(
|
||||
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))),
|
||||
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))),
|
||||
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))),
|
||||
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.end, a.rect))),
|
||||
),
|
||||
);
|
||||
issues.push({
|
||||
@@ -1305,17 +1335,17 @@
|
||||
time,
|
||||
selector: selectorFor(path),
|
||||
containerSelector: selectorFor(svg),
|
||||
message: `Connector path endpoints are ${gap}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`,
|
||||
message: `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`,
|
||||
rect: toRect({
|
||||
left: Math.min(endpoints.start.x, endpoints.end.x),
|
||||
top: Math.min(endpoints.start.y, endpoints.end.y),
|
||||
right: Math.max(endpoints.start.x, endpoints.end.x),
|
||||
bottom: Math.max(endpoints.start.y, endpoints.end.y),
|
||||
width: Math.abs(endpoints.end.x - endpoints.start.x),
|
||||
height: Math.abs(endpoints.end.y - endpoints.start.y),
|
||||
left: Math.min(rendered.start.x, rendered.end.x),
|
||||
top: Math.min(rendered.start.y, rendered.end.y),
|
||||
right: Math.max(rendered.start.x, rendered.end.x),
|
||||
bottom: Math.max(rendered.start.y, rendered.end.y),
|
||||
width: Math.abs(rendered.end.x - rendered.start.x),
|
||||
height: Math.abs(rendered.end.y - rendered.start.y),
|
||||
}),
|
||||
fixHint:
|
||||
"Subtract the SVG's own rect when converting measured coordinates, and keep the SVG a direct child of the stage.",
|
||||
"Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,8 +847,8 @@ describe("layout-audit.browser coordinate-frame findings", () => {
|
||||
// The marker tip path is skipped outright; only the detached line reports.
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ severity: "warning", selector: "#detached" });
|
||||
expect(issues[0]?.message).toContain("drawn into an SVG with a different origin");
|
||||
expect(issues[0]?.fixHint).toContain("Subtract the SVG's own rect");
|
||||
expect(issues[0]?.message).toContain("user-space coordinates would attach");
|
||||
expect(issues[0]?.fixHint).toContain("invert getScreenCTM");
|
||||
});
|
||||
|
||||
it("skips svgs and paths without connector intent", () => {
|
||||
@@ -877,6 +877,254 @@ describe("layout-audit.browser coordinate-frame findings", () => {
|
||||
// "knowledge-overflow" contains conn-family substrings only across word boundaries — no match.
|
||||
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
|
||||
});
|
||||
|
||||
// Counterfactual: decorative paths miss anchors both as rendered and as user-as-screen → not the frame bug.
|
||||
it("skips decorative arrow/flow paths whose user-space coords would not attach either", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="n1"></div>
|
||||
<div id="n2"></div>
|
||||
<svg id="arrow-l" class="arrow">
|
||||
<path id="arrow-glyph" d="M70 20 L10 20" marker-end="url(#tip)" />
|
||||
</svg>
|
||||
<svg id="decor"><path id="flow-line" class="flow-line" d="M-100 200 L2020 880" /></svg>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
|
||||
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
|
||||
"arrow-l": rect({ left: 100, top: 500, width: 80, height: 40 }),
|
||||
decor: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
},
|
||||
{
|
||||
n1: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
n2: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
},
|
||||
);
|
||||
installConnectorGeometry({ e: 100, f: 500 });
|
||||
// Full-bleed decor SVG uses identity translate so user-as-screen == rendered (still off-canvas).
|
||||
for (const path of Array.from(document.querySelectorAll("#decor path"))) {
|
||||
Object.defineProperty(path, "getScreenCTM", {
|
||||
value: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
});
|
||||
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
|
||||
Object.defineProperty(path, "getPointAtLength", {
|
||||
value: (length: number) => (length === 0 ? { x: -100, y: 200 } : { x: 2020, y: 880 }),
|
||||
});
|
||||
}
|
||||
const decorSvg = document.getElementById("decor");
|
||||
if (decorSvg) {
|
||||
Object.defineProperty(decorSvg, "createSVGPoint", {
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform(m: { a: number; b: number; c: number; d: number; e: number; f: number }) {
|
||||
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
installAuditScript();
|
||||
|
||||
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
|
||||
});
|
||||
|
||||
// Same DOM node via painted-inside + compact-near-miss must share one identity (not p0 vs c0).
|
||||
it("skips same-anchor cross-tier arrows that only graze one node", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="n1"></div>
|
||||
<div id="n2"></div>
|
||||
<svg id="arrow-svg" class="arrow">
|
||||
<path id="cross-tier" d="M 980 580 L 1080 580" marker-end="url(#tip)" />
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
|
||||
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
|
||||
"arrow-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
|
||||
},
|
||||
{
|
||||
n1: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
n2: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
},
|
||||
);
|
||||
// Raw start inside #n1; raw end just outside #n1 but within attach tolerance — one element.
|
||||
installConnectorGeometry({ e: 80, f: 227 });
|
||||
installAuditScript();
|
||||
|
||||
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
|
||||
});
|
||||
|
||||
// One raw endpoint on a node is not the paste-into-`d` bug (decorative arrow / partial aim).
|
||||
it("skips one-ended decorative arrows when only one user endpoint attaches", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="n1"></div>
|
||||
<div id="n2"></div>
|
||||
<svg id="arrow-svg" class="arrow">
|
||||
<path id="one-ended" d="M 980 580 L 200 100" marker-end="url(#tip)" />
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
|
||||
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
|
||||
"arrow-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
|
||||
},
|
||||
{
|
||||
n1: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
n2: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
},
|
||||
);
|
||||
// CTM offset moves both rendered ends off anchors; raw start sits in #n1, raw end in empty space.
|
||||
installConnectorGeometry({ e: 80, f: 227 });
|
||||
installAuditScript();
|
||||
|
||||
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
|
||||
});
|
||||
|
||||
// Scaled viewBox: user chord can be <32 while screen chord is hundreds of px — must not skip.
|
||||
it("flags foreign-frame connectors when user-space chord is short but screen chord is long", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="n1"></div>
|
||||
<div id="n2"></div>
|
||||
<svg id="scaled-svg" viewBox="0 0 192 108">
|
||||
<path id="short-user" class="connector" d="M 100 58 L 140 58" />
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
// Non-overlapping anchors so both user endpoints hit distinct keys.
|
||||
n1: rect({ left: 70, top: 40, width: 50, height: 40 }),
|
||||
n2: rect({ left: 125, top: 40, width: 50, height: 40 }),
|
||||
"scaled-svg": rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
},
|
||||
{
|
||||
n1: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
n2: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
},
|
||||
);
|
||||
// 10× viewBox scale: user chord 30 (< old 32px gate) → screen chord 300.
|
||||
const path = document.getElementById("short-user");
|
||||
const svg = document.getElementById("scaled-svg");
|
||||
const matrix = { a: 10, b: 0, c: 0, d: 10, e: 0, f: 0 };
|
||||
const prop = { configurable: true, writable: true };
|
||||
if (path) {
|
||||
Object.defineProperty(path, "getTotalLength", { ...prop, value: () => 30 });
|
||||
Object.defineProperty(path, "getPointAtLength", {
|
||||
...prop,
|
||||
value: (length: number) => (length === 0 ? { x: 100, y: 58 } : { x: 140, y: 58 }),
|
||||
});
|
||||
Object.defineProperty(path, "getScreenCTM", { ...prop, value: () => matrix });
|
||||
}
|
||||
if (svg) {
|
||||
Object.defineProperty(svg, "createSVGPoint", {
|
||||
...prop,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform(m: typeof matrix) {
|
||||
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
installAuditScript();
|
||||
|
||||
const issues = runAudit().filter((issue) => issue.code === "connector_detached");
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ selector: "#short-user" });
|
||||
});
|
||||
|
||||
// Closed glyph: rendered chord ~0 — not a two-ended frame bug even if the point sits on a node.
|
||||
it("skips closed filled glyphs whose user-space endpoints collapse", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="n1"></div>
|
||||
<div id="n2"></div>
|
||||
<svg id="arrow-svg" class="arrow">
|
||||
<path id="main-arrow" d="M10 10 L90 10 L50 90 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
|
||||
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
|
||||
"arrow-svg": rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
},
|
||||
{
|
||||
n1: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
n2: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
},
|
||||
);
|
||||
// Closed path: start≈end in user space (and after CTM).
|
||||
for (const path of Array.from(document.querySelectorAll("#main-arrow"))) {
|
||||
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
|
||||
Object.defineProperty(path, "getPointAtLength", {
|
||||
value: () => ({ x: 980, y: 580 }),
|
||||
});
|
||||
Object.defineProperty(path, "getScreenCTM", {
|
||||
value: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
});
|
||||
}
|
||||
const svg = document.getElementById("arrow-svg");
|
||||
if (svg) {
|
||||
Object.defineProperty(svg, "createSVGPoint", {
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform(m: { a: number; b: number; c: number; d: number; e: number; f: number }) {
|
||||
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
installAuditScript();
|
||||
|
||||
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
|
||||
});
|
||||
|
||||
// Correct inverse-CTM authoring: rendered attaches → never flag, even with an offset SVG.
|
||||
it("skips connectors whose rendered endpoints already attach", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="n1"></div>
|
||||
<div id="n2"></div>
|
||||
<svg id="connector-svg">
|
||||
<path id="anchored-only" class="connector-line" d="M 900 353 L 300 53" />
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
installGeometry(
|
||||
{
|
||||
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
|
||||
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
|
||||
"connector-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
|
||||
},
|
||||
{
|
||||
n1: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
n2: { backgroundColor: "rgb(30, 40, 50)" },
|
||||
},
|
||||
);
|
||||
installConnectorGeometry({ e: 80, f: 227 });
|
||||
installAuditScript();
|
||||
|
||||
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout-audit.browser content overlap", () => {
|
||||
@@ -1954,10 +2202,12 @@ interface CtmTranslate {
|
||||
}
|
||||
|
||||
// happy-dom has no SVG geometry APIs; endpoints come from the path's `d`, the CTM is a pure translate.
|
||||
function installConnectorGeometry(translate: CtmTranslate): void {
|
||||
function installConnectorGeometry(translate: CtmTranslate, root: ParentNode = document): void {
|
||||
const matrix = { a: 1, b: 0, c: 0, d: 1, e: translate.e, f: translate.f };
|
||||
for (const svg of Array.from(document.querySelectorAll("svg"))) {
|
||||
const prop = { configurable: true, writable: true };
|
||||
for (const svg of Array.from(root.querySelectorAll("svg"))) {
|
||||
Object.defineProperty(svg, "createSVGPoint", {
|
||||
...prop,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1970,11 +2220,12 @@ function installConnectorGeometry(translate: CtmTranslate): void {
|
||||
const numbers = (path.getAttribute("d")?.match(/-?\d*\.?\d+/g) || []).map(Number);
|
||||
const start = { x: numbers[0] ?? 0, y: numbers[1] ?? 0 };
|
||||
const end = { x: numbers[numbers.length - 2] ?? 0, y: numbers[numbers.length - 1] ?? 0 };
|
||||
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
|
||||
Object.defineProperty(path, "getTotalLength", { ...prop, value: () => 100 });
|
||||
Object.defineProperty(path, "getPointAtLength", {
|
||||
...prop,
|
||||
value: (length: number) => (length === 0 ? start : end),
|
||||
});
|
||||
Object.defineProperty(path, "getScreenCTM", { value: () => matrix });
|
||||
Object.defineProperty(path, "getScreenCTM", { ...prop, value: () => matrix });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,34 @@ import type { RotationSample } from "./checkTypes.js";
|
||||
|
||||
const CANVAS = { width: 1000, height: 1000 };
|
||||
|
||||
/** One rotation sample; defaults describe a large, size-stable element. */
|
||||
/** AABB of an unrotated (elemW×elemH) box after CSS rotation — mirrors the detector model. */
|
||||
function rotatedAabb(elemW: number, elemH: number, angleDeg: number): { w: number; h: number } {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const cosAbs = Math.abs(Math.cos(rad));
|
||||
const sinAbs = Math.abs(Math.sin(rad));
|
||||
return { w: elemW * cosAbs + elemH * sinAbs, h: elemW * sinAbs + elemH * cosAbs };
|
||||
}
|
||||
|
||||
/** One rotation sample; defaults describe a large square. */
|
||||
function sample(overrides: Partial<RotationSample> = {}): RotationSample {
|
||||
return { time: 0, selector: "#spokes", cx: 250, cy: 250, w: 200, h: 200, angle: 0, ...overrides };
|
||||
}
|
||||
|
||||
/** A group that SHOULD fire: spins (0→90→180), size-stable, sizable, and its
|
||||
* bbox center travels 50px — the wrong-pivot signature. threshold here is
|
||||
* max(0.1*200, 0.02*1000) = 20px, so 50px drift clears it. */
|
||||
/** Rigid rectangle sample: AABB is derived from unrotated size + angle. */
|
||||
function rigidSample(
|
||||
elemW: number,
|
||||
elemH: number,
|
||||
overrides: Partial<RotationSample> & { angle: number },
|
||||
): RotationSample {
|
||||
return sample({ ...rotatedAabb(elemW, elemH, overrides.angle), ...overrides });
|
||||
}
|
||||
|
||||
/** A group that SHOULD fire: rigid square spin with bbox center traveling 50px. */
|
||||
function driftingSpinner(): RotationSample[] {
|
||||
return [
|
||||
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 180, cx: 250, cy: 300 }),
|
||||
rigidSample(200, 200, { time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
rigidSample(200, 200, { time: 1, angle: 90, cx: 250, cy: 280 }),
|
||||
rigidSample(200, 200, { time: 2, angle: 180, cx: 250, cy: 300 }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,9 +57,9 @@ describe("detectRotationPivotDrift", () => {
|
||||
// Stage 2 — real spin. A translating-but-not-spinning element is not our bug.
|
||||
it("does not fire when the element barely rotates (fixed tilt, not spinning)", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 2, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 4, cx: 250, cy: 300 }),
|
||||
rigidSample(200, 200, { time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
rigidSample(200, 200, { time: 1, angle: 2, cx: 250, cy: 280 }),
|
||||
rigidSample(200, 200, { time: 2, angle: 4, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
@@ -52,16 +67,14 @@ describe("detectRotationPivotDrift", () => {
|
||||
// Stage 3 — size stability, WIDTH axis (scale/entrance, not pivot drift).
|
||||
it("does not fire when width scales across samples", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 100, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, w: 200, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 180, w: 300, cx: 250, cy: 300 }),
|
||||
sample({ time: 0, angle: 0, w: 100, h: 200, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, w: 200, h: 200, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: 180, w: 300, h: 200, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 3 — size stability, HEIGHT axis. Regression for the width-only guard:
|
||||
// fixed width, top-anchored height growth (top=100 → cy = 100 + h/2) drifts
|
||||
// the AABB center 50px on its own. Must NOT be reported as pivot drift.
|
||||
// Stage 3 — single-axis scale: fixed width + growing height moves the AABB center without a bad pivot.
|
||||
it("does not fire when height scales (top-anchored) even though the AABB center moves", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 100, h: 50, cx: 250, cy: 125 }),
|
||||
@@ -71,6 +84,73 @@ describe("detectRotationPivotDrift", () => {
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Elongated rotators: every AABB fits one 400×80 rectangle; center drifts → fire.
|
||||
it("fires on an elongated spinner whose long side is stable but per-axis AABB swings", () => {
|
||||
const group = [
|
||||
rigidSample(400, 80, { time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
rigidSample(400, 80, { time: 1, angle: 45, cx: 250, cy: 310 }),
|
||||
rigidSample(400, 80, { time: 2, angle: 90, cx: 250, cy: 370 }),
|
||||
];
|
||||
const findings = detectRotationPivotDrift(group, CANVAS);
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.code).toBe("rotation_pivot_drift");
|
||||
});
|
||||
|
||||
// Partial arc with no 90° pair: still one rigid 400×80 rectangle across 0°→20°→40°.
|
||||
it("fires on a rigid elongated partial arc without a 90-degree sample pair", () => {
|
||||
const group = [
|
||||
rigidSample(400, 80, { time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
rigidSample(400, 80, { time: 1, angle: 20, cx: 250, cy: 310 }),
|
||||
rigidSample(400, 80, { time: 2, angle: 40, cx: 250, cy: 370 }),
|
||||
];
|
||||
const findings = detectRotationPivotDrift(group, CANVAS);
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.code).toBe("rotation_pivot_drift");
|
||||
});
|
||||
|
||||
// All samples singular (|cos2θ|<0.15): no invertible estimator — near-square AABB agreement is the fallback.
|
||||
it("fires on a rigid elongated spin sampled only at singular 45-degree-class phases", () => {
|
||||
const group = [
|
||||
rigidSample(400, 80, { time: 0, angle: 45, cx: 250, cy: 250 }),
|
||||
rigidSample(400, 80, { time: 1, angle: 135, cx: 250, cy: 310 }),
|
||||
rigidSample(400, 80, { time: 2, angle: 225, cx: 250, cy: 370 }),
|
||||
];
|
||||
const findings = detectRotationPivotDrift(group, CANVAS);
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.code).toBe("rotation_pivot_drift");
|
||||
expect(findings[0]?.message).toContain("120px");
|
||||
});
|
||||
|
||||
// Two-axis scale/entrance: AABBs are not one rotated rectangle.
|
||||
it("does not fire on two-axis scale/entrance that shrinks the long side while growing the short", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 400, h: 100, cx: 200, cy: 50 }),
|
||||
sample({ time: 1, angle: 45, w: 340, h: 200, cx: 170, cy: 100 }),
|
||||
sample({ time: 2, angle: 90, w: 300, h: 300, cx: 150, cy: 150 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Scale pulse returns to the start size mid-trajectory — still not one rigid rectangle.
|
||||
it("does not fire on a scale pulse that returns to the start AABB", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 400, h: 100, cx: 200, cy: 50 }),
|
||||
sample({ time: 1, angle: 45, w: 340, h: 300, cx: 170, cy: 150 }),
|
||||
sample({ time: 2, angle: 90, w: 400, h: 100, cx: 200, cy: 50 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// One valid 90° swap then a later scale — must not over-admit via pairwise swap.
|
||||
it("does not fire when an early axis-swap is followed by a non-rigid scale sample", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, w: 400, h: 100, cx: 200, cy: 50 }),
|
||||
sample({ time: 1, angle: 90, w: 100, h: 400, cx: 150, cy: 150 }),
|
||||
sample({ time: 2, angle: 180, w: 400, h: 300, cx: 200, cy: 250 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not fire when a sample has a degenerate zero dimension", () => {
|
||||
const group = driftingSpinner().map((s, i) => (i === 0 ? { ...s, w: 0 } : s));
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
@@ -78,27 +158,31 @@ describe("detectRotationPivotDrift", () => {
|
||||
|
||||
// Stage 4 — sizable. Tiny decorative spinners are ignored (area < 2500px²).
|
||||
it("does not fire on a tiny element below the median-area floor", () => {
|
||||
const group = driftingSpinner().map((s) => ({ ...s, w: 40, h: 40 }));
|
||||
const group = [
|
||||
rigidSample(40, 40, { time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
rigidSample(40, 40, { time: 1, angle: 90, cx: 250, cy: 280 }),
|
||||
rigidSample(40, 40, { time: 2, angle: 180, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Stage 5 — center drift. A correctly-centered spinner holds its bbox center.
|
||||
it("does not fire on a spinner whose bbox center stays put", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0 }),
|
||||
sample({ time: 1, angle: 120 }),
|
||||
sample({ time: 2, angle: 240 }),
|
||||
rigidSample(200, 200, { time: 0, angle: 0 }),
|
||||
rigidSample(200, 200, { time: 1, angle: 120 }),
|
||||
rigidSample(200, 200, { time: 2, angle: 240 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the viewport floor when the element is small relative to a large canvas", () => {
|
||||
// medianSize=200 → sizeFloor=20; viewportFloor on a 3000px canvas = 60.
|
||||
// medianSize≈200 → sizeFloor=20; viewportFloor on a 3000px canvas = 60.
|
||||
// A 40px drift is below 60 → clean; the same group fired on CANVAS above.
|
||||
const group = [
|
||||
sample({ time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 90, cx: 250, cy: 270 }),
|
||||
sample({ time: 2, angle: 180, cx: 250, cy: 290 }),
|
||||
rigidSample(200, 200, { time: 0, angle: 0, cx: 250, cy: 250 }),
|
||||
rigidSample(200, 200, { time: 1, angle: 90, cx: 250, cy: 270 }),
|
||||
rigidSample(200, 200, { time: 2, angle: 180, cx: 250, cy: 290 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, { width: 3000, height: 3000 })).toHaveLength(0);
|
||||
});
|
||||
@@ -114,9 +198,9 @@ describe("detectRotationPivotDrift", () => {
|
||||
// as a fast spin and (with a drifting center) fire falsely.
|
||||
it("does not treat a ±180° boundary wobble as spinning", () => {
|
||||
const group = [
|
||||
sample({ time: 0, angle: -175, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: 175, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: -172, cx: 250, cy: 300 }),
|
||||
rigidSample(200, 200, { time: 0, angle: -175, cx: 250, cy: 250 }),
|
||||
rigidSample(200, 200, { time: 1, angle: 175, cx: 250, cy: 280 }),
|
||||
rigidSample(200, 200, { time: 2, angle: -172, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(0);
|
||||
});
|
||||
@@ -124,9 +208,9 @@ describe("detectRotationPivotDrift", () => {
|
||||
it("still detects a real spin that crosses the ±180° boundary", () => {
|
||||
// 170° → -100° → -10° is ~270° of genuine travel across the seam.
|
||||
const group = [
|
||||
sample({ time: 0, angle: 170, cx: 250, cy: 250 }),
|
||||
sample({ time: 1, angle: -100, cx: 250, cy: 280 }),
|
||||
sample({ time: 2, angle: -10, cx: 250, cy: 300 }),
|
||||
rigidSample(200, 200, { time: 0, angle: 170, cx: 250, cy: 250 }),
|
||||
rigidSample(200, 200, { time: 1, angle: -100, cx: 250, cy: 280 }),
|
||||
rigidSample(200, 200, { time: 2, angle: -10, cx: 250, cy: 300 }),
|
||||
];
|
||||
expect(detectRotationPivotDrift(group, CANVAS)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -502,19 +502,16 @@ function detectSweepStatic(
|
||||
];
|
||||
}
|
||||
|
||||
// rotation_pivot_drift thresholds. A spinning element's bbox center should be
|
||||
// fixed; drift beyond this signals a wrong pivot (transformOrigin/svgOrigin).
|
||||
// rotation_pivot_drift: bbox center should stay fixed while the element spins.
|
||||
const ROTATION_MIN_SAMPLES = 3;
|
||||
// Degrees of angle spread that count as "actually spinning" (vs a static tilt).
|
||||
// Minimum angle spread that counts as spinning rather than a static tilt.
|
||||
const ROTATION_MIN_ANGLE_SPREAD_DEG = 20;
|
||||
// Max bbox-width ratio across samples — above this it is scaling/entrancing,
|
||||
// not spinning in place. A rigid anisotropic shape's axis-aligned bbox
|
||||
// oscillates under rotation on its own (a plain square already swings 1.41x
|
||||
// between flat and 45deg), so this must sit above that; 1.6 admits rotating
|
||||
// squares/mild rectangles while still excluding gross scale/entrance growth
|
||||
// and thin bars/lines whose bbox swings many-fold (e.g. a rotating reference
|
||||
// arm). The bbox-CENTER drift below is the real spin-in-place discriminator.
|
||||
// Long-axis AABB growth ceiling (square@45° ≈ 1.41×); rejects non-rigid blow-ups before the model fit.
|
||||
const ROTATION_MAX_SIZE_RATIO = 1.6;
|
||||
// Relative slack when matching observed AABB to one rigid unrotated rectangle.
|
||||
const ROTATION_RIGID_AABB_RATIO = 1.15;
|
||||
// |cos2θ| below this → 45°-class sample; skip as an unrotated-size estimator (singular).
|
||||
const ROTATION_RIGID_ESTIMATE_MIN_DET = 0.15;
|
||||
// Skip tiny decorative spinners; only sizable rotating figures matter.
|
||||
const ROTATION_MIN_MEDIAN_AREA_PX = 2500;
|
||||
const ROTATION_DRIFT_SIZE_FRACTION = 0.1;
|
||||
@@ -616,30 +613,98 @@ function isActuallySpinning(group: RotationSample[]): boolean {
|
||||
return maxAngleSpread(group.map((s) => s.angle)) > ROTATION_MIN_ANGLE_SPREAD_DEG;
|
||||
}
|
||||
|
||||
/** Rigid bbox size in BOTH dimensions. A scale/entrance animation is not pivot
|
||||
* drift; in particular top-anchored height scaling (fixed width, growing height)
|
||||
* moves the AABB center on its own — the earlier width-only guard let that through
|
||||
* as a false positive. */
|
||||
function isRotationSizeStable(group: RotationSample[]): boolean {
|
||||
const widths = group.map((s) => s.w);
|
||||
const heights = group.map((s) => s.h);
|
||||
const minWidth = Math.min(...widths);
|
||||
const minHeight = Math.min(...heights);
|
||||
if (minWidth <= 0 || minHeight <= 0) return false;
|
||||
/** AABB of an axis-aligned rectangle of size (elemW, elemH) after CSS rotation `angleDeg`. */
|
||||
function aabbForRotatedRect(
|
||||
elemW: number,
|
||||
elemH: number,
|
||||
angleDeg: number,
|
||||
): { w: number; h: number } {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const cosAbs = Math.abs(Math.cos(rad));
|
||||
const sinAbs = Math.abs(Math.sin(rad));
|
||||
return { w: elemW * cosAbs + elemH * sinAbs, h: elemW * sinAbs + elemH * cosAbs };
|
||||
}
|
||||
|
||||
/** Invert one sample to unrotated (elemW, elemH); null when θ is near 45° (singular). */
|
||||
function unrotatedSizeFromSample(sample: RotationSample): { w: number; h: number } | null {
|
||||
const rad = (sample.angle * Math.PI) / 180;
|
||||
const cosAbs = Math.abs(Math.cos(rad));
|
||||
const sinAbs = Math.abs(Math.sin(rad));
|
||||
const det = cosAbs * cosAbs - sinAbs * sinAbs; // cos(2θ)
|
||||
if (Math.abs(det) < ROTATION_RIGID_ESTIMATE_MIN_DET) return null;
|
||||
const elemW = (cosAbs * sample.w - sinAbs * sample.h) / det;
|
||||
const elemH = (cosAbs * sample.h - sinAbs * sample.w) / det;
|
||||
if (!(elemW > 0) || !(elemH > 0)) return null;
|
||||
return { w: elemW, h: elemH };
|
||||
}
|
||||
|
||||
function aabbMatchesSample(expected: { w: number; h: number }, sample: RotationSample): boolean {
|
||||
if (expected.w <= 0 || expected.h <= 0 || sample.w <= 0 || sample.h <= 0) return false;
|
||||
return (
|
||||
Math.max(...widths) / minWidth <= ROTATION_MAX_SIZE_RATIO &&
|
||||
Math.max(...heights) / minHeight <= ROTATION_MAX_SIZE_RATIO
|
||||
Math.max(expected.w, sample.w) / Math.min(expected.w, sample.w) <= ROTATION_RIGID_AABB_RATIO &&
|
||||
Math.max(expected.h, sample.h) / Math.min(expected.h, sample.h) <= ROTATION_RIGID_AABB_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function isSingularRotationAngle(angleDeg: number): boolean {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const cosAbs = Math.abs(Math.cos(rad));
|
||||
const sinAbs = Math.abs(Math.sin(rad));
|
||||
return Math.abs(cosAbs * cosAbs - sinAbs * sinAbs) < ROTATION_RIGID_ESTIMATE_MIN_DET;
|
||||
}
|
||||
|
||||
function isNearSquareAabb(sample: RotationSample): boolean {
|
||||
if (sample.w <= 0 || sample.h <= 0) return false;
|
||||
return Math.max(sample.w, sample.h) / Math.min(sample.w, sample.h) <= ROTATION_RIGID_AABB_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* All samples are 45°-class (no invertible estimator): a rigid rectangle projects to one
|
||||
* near-square AABB size at every such phase, so mutual AABB agreement is the rigidity proof.
|
||||
*/
|
||||
function fitsSingularPhaseRigidProjection(group: RotationSample[]): boolean {
|
||||
if (group.length === 0 || !group.every((s) => isSingularRotationAngle(s.angle))) return false;
|
||||
if (!group.every(isNearSquareAabb)) return false;
|
||||
const ref = group[0];
|
||||
if (!ref) return false;
|
||||
return group.every((sample) => aabbMatchesSample({ w: ref.w, h: ref.h }, sample));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sample's AABB matches one fixed unrotated rectangle spun by that sample's angle.
|
||||
* Scale/entrance fails; partial-arc and all-singular (45°-class) rigid spins still pass.
|
||||
*/
|
||||
function fitsOneRigidRectangle(group: RotationSample[]): boolean {
|
||||
for (const ref of group) {
|
||||
const size = unrotatedSizeFromSample(ref);
|
||||
if (!size) continue;
|
||||
if (
|
||||
group.every((sample) =>
|
||||
aabbMatchesSample(aabbForRotatedRect(size.w, size.h, sample.angle), sample),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return fitsSingularPhaseRigidProjection(group);
|
||||
}
|
||||
|
||||
/** Rigid spin: long AABB side stays bounded, and all samples fit one rotated rectangle. */
|
||||
function isRotationSizeStable(group: RotationSample[]): boolean {
|
||||
if (group.some((s) => s.w <= 0 || s.h <= 0)) return false;
|
||||
const longSides = group.map((s) => Math.max(s.w, s.h));
|
||||
const minLong = Math.min(...longSides);
|
||||
if (minLong <= 0) return false;
|
||||
if (Math.max(...longSides) / minLong > ROTATION_MAX_SIZE_RATIO) return false;
|
||||
return fitsOneRigidRectangle(group);
|
||||
}
|
||||
|
||||
/** Skip tiny decorative spinners; only sizable rotating figures matter. */
|
||||
function isSizableRotation(group: RotationSample[]): boolean {
|
||||
return median(group.map((s) => s.w * s.h)) >= ROTATION_MIN_MEDIAN_AREA_PX;
|
||||
}
|
||||
|
||||
/** The size/motion gates a selector group must clear before the (viewport-
|
||||
* dependent) center-drift test. Each is a strict FP guard, deliberately so:
|
||||
* a false positive feeds destructive downstream auto-fixes. */
|
||||
/** Size/motion FP gates before the viewport-dependent center-drift test. */
|
||||
function isRotationDriftCandidate(group: RotationSample[]): boolean {
|
||||
return (
|
||||
hasEnoughRotationSamples(group) &&
|
||||
@@ -649,27 +714,7 @@ function isRotationDriftCandidate(group: RotationSample[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* rotation_pivot_drift: an element that visibly SPINS (its rotation angle varies
|
||||
* across the seek grid) while its bounding-box CENTER travels is pivoting about
|
||||
* the wrong point — the classic symptom of a transformOrigin/svgOrigin authored
|
||||
* as hardcoded pixels against a coordinate space the element was later resized
|
||||
* out of (e.g. spokes set to `250px 250px` inside a 460px-rendered 500-viewBox
|
||||
* SVG). A correctly centered spinner holds its bbox center fixed.
|
||||
*
|
||||
* Cross-sample by necessity — one frame can't distinguish spin-in-place from
|
||||
* pivot drift. FP guards are deliberately strict because a false positive feeds
|
||||
* destructive downstream auto-fixes: requires real rotation, a stable bbox size
|
||||
* in both axes (excludes scale/entrance animations), a sizable element, and
|
||||
* honors `[data-layout-allow-orbit]` opt-outs (applied browser-side).
|
||||
*
|
||||
* Invariant: samples are grouped by `selector`, assumed stable across seeks.
|
||||
* An element without a stable id/class can fall back to a `nth-of-type(N)`
|
||||
* selector whose N shifts as siblings enter/exit — so in that fringe it may be
|
||||
* mis-grouped (a missed detection, or in a rare exit-then-enter aliasing case a
|
||||
* spurious one). Author-crafted rotating figures effectively always carry stable
|
||||
* anchors; a stable-anchor gate on the browser sampler is the structural fix.
|
||||
*/
|
||||
/** Flags a spinning element whose bbox center drifts — wrong transformOrigin/svgOrigin (elongated rotators included). */
|
||||
export function detectRotationPivotDrift(
|
||||
samples: RotationSample[],
|
||||
canvas: Canvas,
|
||||
|
||||
Reference in New Issue
Block a user