Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
collectLayout: vi.fn(async (_time: number, _tolerance: number) => []),
collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`),
collectRotationSample: vi.fn(async (_time: number) => []),
collectNeedlePivotSample: vi.fn(async (_time: number) => []),
collectGeometryCandidates: vi.fn(async () => []),
collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })),
anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) =>
Expand Down
184 changes: 184 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1531,4 +1531,188 @@
}
return samples;
};

// Needle-pivot sampling (needle_pivot_offset). A gauge/clock/radar pointer
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
// center orbits either way), so this records two MATERIAL points on each
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
// rendered transform is honored regardless of svgOrigin/transform-origin — and
// the dial's static hub (the point shared by the most non-rotating circles).
// The pipeline fits a rotation to the material-point trajectories to recover
// the real center-of-rotation and flags it when it drifts off that hub.
function ctmRotationDeg(ctm) {
if (!ctm) return null;
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
}

function ctmScale(ctm) {
return Math.hypot(ctm.a, ctm.b);
}

function mapPoint(svg, ctm, x, y) {
const point = svg.createSVGPoint();
point.x = x;
point.y = y;
const mapped = point.matrixTransform(ctm);
return { x: mapped.x, y: mapped.y };
}

// Walks up to (and including) the composition root, NOT just the owner <svg>:
// an element spun by a div ancestor above its svg must not be mistaken for a
// static hub anchor (else a lone rotating arc becomes its own dial center).
function hasRotatedAncestor(element, root) {
let node = element;
while (node) {
const angle = rotationAngleDeg(getComputedStyle(node).transform);
if (angle !== null && Math.abs(angle) > 1) return true;
if (node === root) break;
node = node.parentElement;
}
return false;
}

function fitCirclePoints(points) {
const count = points.length;
if (count < 3) return null;
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
let suu = 0,
svv = 0,
suv = 0,
suuu = 0,
svvv = 0,
suvv = 0,
svuu = 0;
for (const point of points) {
const u = point.x - meanX;
const v = point.y - meanY;
suu += u * u;
svv += v * v;
suv += u * v;
suuu += u * u * u;
svvv += v * v * v;
suvv += u * v * v;
svuu += v * u * u;
}
const det = suu * svv - suv * suv;
if (Math.abs(det) < 1e-6) return null;
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
const cx = uc + meanX;
const cy = vc + meanY;
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
let residual = 0;
for (const point of points)
residual += Math.abs(Math.hypot(point.x - cx, point.y - cy) - radius);
return { cx, cy, radius, residual: residual / count };
}

// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
// the largest static, near-circular path and recover its arc center.
function arcHubForSvg(svg, root) {
let best = null;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (hasRotatedAncestor(path, root)) continue;
if (typeof path.getTotalLength !== "function") continue;
const total = path.getTotalLength();
if (total < 200) continue;
const ctm = path.getScreenCTM();
if (!ctm) continue;
const points = [];
for (let i = 0; i <= 16; i++) {
const local = path.getPointAtLength((total * i) / 16);
points.push(mapPoint(svg, ctm, local.x, local.y));
}
const fit = fitCirclePoints(points);
if (!fit || fit.radius < 40) continue;
if (fit.residual > 0.05 * fit.radius) continue;
if (!best || fit.radius > best.radius) best = fit;
}
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
}

function dialHubForSvg(svg, root) {
const centers = [];
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
if (hasRotatedAncestor(circle, root)) continue;
const ctm = circle.getScreenCTM();
if (!ctm) continue;
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
const center = mapPoint(svg, ctm, cx, cy);
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
centers.push({ x: center.x, y: center.y, radius });
}
let best = null;
for (const anchor of centers) {
const cluster = centers.filter(
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
);
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
}
if (best && best.cluster.length >= 2) {
const count = best.cluster.length;
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
return { hx, hy, hr, count };
}
return arcHubForSvg(svg, root);
}

window.__hyperframesNeedlePivotSample = function collectNeedlePivotSample() {
const root = document.querySelector("[data-composition-id]") || document.body;
const samples = [];
const hubCache = new Map();
const CANDIDATE_CAP = 60;
for (const element of Array.from(
root.querySelectorAll("path, polygon, line, rect, polyline, g"),
)) {
if (samples.length >= CANDIDATE_CAP) break;
const svg = element.ownerSVGElement;
if (!svg || typeof element.getBBox !== "function") continue;
if (element.closest("[data-layout-allow-orbit]")) continue;
if (!isVisibleElement(element, 0.05)) continue;
const ctm = element.getScreenCTM();
const angle = ctmRotationDeg(ctm);
if (ctm === null || angle === null) continue;
let bbox;
try {
bbox = element.getBBox();
} catch {
continue;
}
const long = Math.max(bbox.width, bbox.height);
const short = Math.min(bbox.width, bbox.height);
if (short <= 0 || long / short < 3 || long < 40) continue;
const vertical = bbox.height >= bbox.width;
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
const a = vertical
? mapPoint(svg, ctm, midMajor, bbox.y)
: mapPoint(svg, ctm, bbox.x, midMajor);
const b = vertical
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
let hub = hubCache.get(svg);
if (hub === undefined) {
hub = dialHubForSvg(svg, root);
hubCache.set(svg, hub);
}
samples.push({
selector: selectorFor(element),
ax: round(a.x),
ay: round(a.y),
bx: round(b.x),
by: round(b.y),
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
angle: round(angle),
hx: hub ? round(hub.hx) : null,
hy: hub ? round(hub.hy) : null,
hr: hub ? round(hub.hr) : null,
hubCount: hub ? hub.count : 0,
});
}
return samples;
};
})();
53 changes: 53 additions & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
ContrastCapture,
GeometryCandidateRequest,
MotionSpecResolution,
NeedlePivotSample,
RotationSample,
RunAuditGrid,
} from "./checkTypes.js";
Expand Down Expand Up @@ -349,6 +350,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
collectLayout: (time, tolerance) => collectLayout(page, time, tolerance),
collectLayoutGeometry: () => collectLayoutGeometry(page),
collectRotationSample: (time) => collectRotationSample(page, time),
collectNeedlePivotSample: (time) => collectNeedlePivotSample(page, time),
collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request),
collectMotionFrame: (time, selectors, scopes) =>
collectMotionFrame(page, time, selectors, scopes),
Expand Down Expand Up @@ -494,6 +496,56 @@ function parseRotationSample(value: unknown, time: number): RotationSample[] {
return [{ time, selector, cx, cy, w, h, angle }];
}

async function collectNeedlePivotSample(page: Page, time: number): Promise<NeedlePivotSample[]> {
const raw = await page.evaluate(() => {
const sample = Reflect.get(window, "__hyperframesNeedlePivotSample");
if (typeof sample !== "function") return [];
const result = Reflect.apply(sample, window, []);
return Array.isArray(result) ? result : [];
});
return raw.flatMap((value) => parseNeedlePivotSample(value, time));
}

function parseNeedlePivotSample(value: unknown, time: number): NeedlePivotSample[] {
if (!isRecord(value)) return [];
const selector = stringValue(value, "selector");
const ax = numberValue(value, "ax");
const ay = numberValue(value, "ay");
const bx = numberValue(value, "bx");
const by = numberValue(value, "by");
const len = numberValue(value, "len");
const angle = numberValue(value, "angle");
const hubCount = numberValue(value, "hubCount");
if (
!selector ||
ax === null ||
ay === null ||
bx === null ||
by === null ||
len === null ||
angle === null ||
hubCount === null
) {
return [];
}
return [
{
time,
selector,
ax,
ay,
bx,
by,
len,
angle,
hx: numberValue(value, "hx"),
hy: numberValue(value, "hy"),
hr: numberValue(value, "hr"),
hubCount,
},
];
}

async function collectGeometryCandidates(
page: Page,
time: number,
Expand Down Expand Up @@ -1078,6 +1130,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"panel_out_of_canvas",
"connector_detached",
"rotation_pivot_drift",
"needle_pivot_offset",
"motion_appears_late",
"motion_out_of_order",
"motion_off_frame",
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/utils/checkPipeline.needlePivotOffset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";

import { detectNeedlePivotOffset } from "./checkPipeline.js";
import type { NeedlePivotSample } from "./checkTypes.js";

/** One needle sample; defaults describe a sizable pointer on a hub at (500,500). */
function sample(overrides: Partial<NeedlePivotSample> = {}): NeedlePivotSample {
return {
time: 0,
selector: "#needle",
ax: 0,
ay: 0,
bx: 0,
by: 0,
len: 200,
angle: 0,
hx: 500,
hy: 500,
hr: 220,
hubCount: 3,
...overrides,
};
}

/** Endpoint positions on a circle of `radius` about `(cx,cy)` at 0/90/180°. */
function orbit(cx: number, cy: number, radius: number): Array<[number, number]> {
return [
[cx + radius, cy],
[cx, cy + radius],
[cx - radius, cy],
];
}

/** A group that SHOULD fire: the pointer sweeps (0→90→180) about a recovered
* center at (500,300) — 200px above the dial hub at (500,500). Two material
* endpoints trace concentric circles about that wrong center. */
function offPivotNeedle(selector = "#needle"): NeedlePivotSample[] {
const a = orbit(500, 300, 100);
const b = orbit(500, 300, 40);
return [0, 1, 2].map((i) =>
sample({
selector,
time: i,
angle: i * 90,
ax: a[i]?.[0] ?? 0,
ay: a[i]?.[1] ?? 0,
bx: b[i]?.[0] ?? 0,
by: b[i]?.[1] ?? 0,
}),
);
}

describe("detectNeedlePivotOffset", () => {
it("fires when the recovered center-of-rotation is far from the dial hub", () => {
const findings = detectNeedlePivotOffset(offPivotNeedle());
expect(findings).toHaveLength(1);
const [f] = findings;
expect(f?.code).toBe("needle_pivot_offset");
expect(f?.severity).toBe("warning");
expect(f?.selector).toBe("#needle");
expect(f?.message).toContain("200px");
expect(f?.fixHint).toContain("hub");
});

it("does not fire with fewer than the minimum samples", () => {
expect(detectNeedlePivotOffset(offPivotNeedle().slice(0, 2))).toHaveLength(0);
});

it("does not fire when the pointer barely sweeps (fixed tilt)", () => {
const group = offPivotNeedle().map((s, i) => ({ ...s, angle: i * 3 }));
expect(detectNeedlePivotOffset(group)).toHaveLength(0);
});

it("does not fire when no dial hub is resolvable", () => {
const group = offPivotNeedle().map((s) => ({ ...s, hx: null, hy: null, hubCount: 0 }));
expect(detectNeedlePivotOffset(group)).toHaveLength(0);
});

it("does not fire when the hub has too few concentric circles", () => {
const group = offPivotNeedle().map((s) => ({ ...s, hubCount: 1 }));
expect(detectNeedlePivotOffset(group)).toHaveLength(0);
});

it("does not fire on a correctly-hubbed needle (center-of-rotation on the hub)", () => {
const a = orbit(500, 500, 100);
const b = orbit(500, 500, 40);
const group = [0, 1, 2].map((i) =>
sample({
time: i,
angle: i * 90,
ax: a[i]?.[0] ?? 0,
ay: a[i]?.[1] ?? 0,
bx: b[i]?.[0] ?? 0,
by: b[i]?.[1] ?? 0,
}),
);
expect(detectNeedlePivotOffset(group)).toHaveLength(0);
});

it("does not fire when the endpoint trajectory is not a clean circle", () => {
const group = [
sample({ time: 0, angle: 0, ax: 600, ay: 300, bx: 540, by: 300 }),
sample({ time: 1, angle: 90, ax: 505, ay: 402, bx: 500, by: 341 }),
sample({ time: 2, angle: 180, ax: 402, ay: 305, bx: 461, by: 299 }),
].map((s) => ({ ...s, ax: s.ax + (s.time === 1 ? 180 : 0) }));
expect(detectNeedlePivotOffset(group)).toHaveLength(0);
});

it("collapses nested candidates on the same hub to a single finding", () => {
const group = offPivotNeedle().map((s) => ({ ...s, len: 200 }));
const child = offPivotNeedle("#needle > path:nth-of-type(1)").map((s) => ({ ...s, len: 150 }));
const findings = detectNeedlePivotOffset([...group, ...child]);
expect(findings).toHaveLength(1);
expect(findings[0]?.selector).toBe("#needle");
});

it("returns nothing for an empty sample set", () => {
expect(detectNeedlePivotOffset([])).toHaveLength(0);
});
});
Loading
Loading