Skip to content

Commit 4678ed1

Browse files
xuanruliclaude
andcommitted
fix(lint): exclude gauge needles/pointers from connector_motion_detached
A gauge needle/pointer/tick is a one-end-anchored indicator, not a node-to-node connector; a separate gauge check owns it. Skip a connector whose element or group ancestor id/class matches needle|pointer|gauge|tick| indicator, and skip gauge-indicator geometry (endpoint pivots near an SVG arc/hub centre with the loose end radially outward). A defective radial connector points the other way so it still fires. Clears the fuzz080 gauge false positive; fuzz011 detach still fires (0 FP across the 81-sample corpus). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3b371c9 commit 4678ed1

3 files changed

Lines changed: 131 additions & 18 deletions

File tree

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,11 +1541,22 @@
15411541
// short strokes are filtered out so only real diagram connectors count.
15421542
const CONNECTOR_MIN_SVG_PX = 100;
15431543
const CONNECTOR_MIN_LEN_PX = 60;
1544+
// Gauge needles/pointers/ticks are one-end-anchored indicators, not node-to-node
1545+
// connectors — a separate (gauge) check owns them. Skip by id/class of the line
1546+
// or any group ancestor up to the SVG.
1547+
const CONNECTOR_INDICATOR_NAME = /needle|pointer|gauge|tick|indicator/i;
15441548
const CONNECTOR_NODE_MIN_AREA = 400;
15451549
// SVG dots/markers are small; keep the floor low but above sub-pixel decoration.
15461550
const CONNECTOR_NODE_MIN_DOT_AREA = 16;
15471551
const CONNECTOR_NODE_CAP = 300;
15481552

1553+
function isIndicatorConnector(line, svg) {
1554+
for (let node = line; node && node !== svg.parentElement; node = node.parentElement) {
1555+
if (CONNECTOR_INDICATOR_NAME.test(connectorNameFor(node))) return true;
1556+
}
1557+
return false;
1558+
}
1559+
15491560
function lineScreenEndpoints(svg, line) {
15501561
if (typeof line.getScreenCTM !== "function" || typeof svg.createSVGPoint !== "function") {
15511562
return null;
@@ -1623,6 +1634,7 @@
16231634
for (const line of Array.from(svg.querySelectorAll("line, path"))) {
16241635
if (line.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
16251636
if (!isVisibleElement(line, 0.05)) continue;
1637+
if (isIndicatorConnector(line, svg)) continue;
16261638
const ends =
16271639
line.tagName.toLowerCase() === "line"
16281640
? lineScreenEndpoints(svg, line)

packages/cli/src/utils/checkPipeline.connectorMotionDetached.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,56 @@ describe("detectConnectorMotionDetached", () => {
105105
expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0);
106106
});
107107

108+
it("does not fire on a gauge needle: anchored at the arc centre, loose end radially outward", () => {
109+
// Arc ring centred at 900,500; needle base on the hub near centre, tip out
110+
// past the arc — a pointer, not a broken connector.
111+
const hub: ConnectorNodeBox = {
112+
selector: "#hub",
113+
left: 890,
114+
top: 490,
115+
right: 910,
116+
bottom: 510,
117+
ring: false,
118+
};
119+
const arc: ConnectorNodeBox = {
120+
selector: "#arc",
121+
left: 700,
122+
top: 300,
123+
right: 1100,
124+
bottom: 700,
125+
ring: true,
126+
};
127+
const frames = [0, 2, 4, 6, 8].map((time) =>
128+
frame(time, { selector: "#needle", ax: 900, ay: 500, bx: 900, by: 180 }, [hub, arc]),
129+
);
130+
expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(0);
131+
});
132+
133+
it("still fires on a radial connector drifting toward the centre (not outward)", () => {
134+
// Anchored on a peripheral node; loose end drifts inward to empty space near
135+
// a ring centre — the fuzz011 shape, opposite of a gauge pointer.
136+
const peripheral: ConnectorNodeBox = {
137+
selector: "#panel",
138+
left: 120,
139+
top: 120,
140+
right: 260,
141+
bottom: 200,
142+
ring: false,
143+
};
144+
const arc: ConnectorNodeBox = {
145+
selector: "#arc",
146+
left: 700,
147+
top: 300,
148+
right: 1100,
149+
bottom: 700,
150+
ring: true,
151+
};
152+
const frames = [0, 2, 4, 6, 8].map((time) =>
153+
frame(time, { selector: "#spoke", ax: 180, ay: 160, bx: 900, by: 500 }, [peripheral, arc]),
154+
);
155+
expect(detectConnectorMotionDetached(frames, CANVAS)).toHaveLength(1);
156+
});
157+
108158
it("still fires when the loose end sits in a ring's hollow centre", () => {
109159
// End B at the ring centre 900,500 is ~200px from its perimeter → dangling.
110160
const frames = [0, 2, 4, 6, 8].map((time) =>

packages/cli/src/utils/checkPipeline.ts

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,30 @@ function isDangling(gaps: EndpointHeldGaps): boolean {
756756
return gaps.detachedFraction >= CONNECTOR_HELD_DETACH_FRAC;
757757
}
758758

759+
// Gauge-indicator geometry: the anchored end pivots near a ring/arc centre and
760+
// the loose end extends radially outward. That is a needle/pointer (owned by a
761+
// separate gauge check), not a broken node connector. A defective radial
762+
// connector points the other way — anchored on a peripheral node, loose end
763+
// drifting toward the centre — so this only excludes true centre-pivot pointers.
764+
const GAUGE_HUB_CENTRE_FRACTION = 0.25;
765+
766+
function isGaugeIndicator(
767+
anchored: { x: number; y: number },
768+
dangling: { x: number; y: number },
769+
nodes: ConnectorNodeBox[],
770+
): boolean {
771+
for (const node of nodes) {
772+
if (!node.ring) continue;
773+
const cx = (node.left + node.right) / 2;
774+
const cy = (node.top + node.bottom) / 2;
775+
const size = Math.max(node.right - node.left, node.bottom - node.top);
776+
const dAnchored = Math.hypot(anchored.x - cx, anchored.y - cy);
777+
const dDangling = Math.hypot(dangling.x - cx, dangling.y - cy);
778+
if (dAnchored <= GAUGE_HUB_CENTRE_FRACTION * size && dDangling > dAnchored) return true;
779+
}
780+
return false;
781+
}
782+
759783
function connectorDetachFinding(
760784
selector: string,
761785
point: { x: number; y: number },
@@ -803,6 +827,49 @@ function connectorDetachFinding(
803827
* lines to a nearby label do not); and a selector that aliases multiple
804828
* connectors in one frame is dropped.
805829
*/
830+
interface DanglePick {
831+
anchored: { x: number; y: number };
832+
dangling: { x: number; y: number };
833+
gap: number;
834+
}
835+
836+
/** If exactly one endpoint is anchored and the other persistently dangles, name
837+
* the anchored/dangling points and the dangling gap; else null. */
838+
function danglingEndpoint(
839+
last: ConnectorObservation,
840+
gapsA: EndpointHeldGaps,
841+
gapsB: EndpointHeldGaps,
842+
): DanglePick | null {
843+
const a = { x: last.ax, y: last.ay };
844+
const b = { x: last.bx, y: last.by };
845+
if (isAnchored(gapsA) && isDangling(gapsB) && !isAnchored(gapsB)) {
846+
return { anchored: a, dangling: b, gap: gapsB.danglingGap };
847+
}
848+
if (isAnchored(gapsB) && isDangling(gapsA) && !isAnchored(gapsA)) {
849+
return { anchored: b, dangling: a, gap: gapsA.danglingGap };
850+
}
851+
return null;
852+
}
853+
854+
/** One connector's held trajectory → a finding, or null. A finding needs one
855+
* endpoint anchored to a node and the other persistently in empty space, and is
856+
* suppressed for gauge-indicator geometry (see isGaugeIndicator). */
857+
function connectorGroupFinding(
858+
selector: string,
859+
group: ConnectorObservation[],
860+
holdStart: number,
861+
detachThreshold: number,
862+
): AnchoredLayoutIssue | null {
863+
const last = group.length >= CONNECTOR_MIN_FRAMES ? group[group.length - 1] : undefined;
864+
if (!last) return null;
865+
const gapsA = endpointHeldGaps(group, (o) => ({ x: o.ax, y: o.ay }), holdStart, detachThreshold);
866+
const gapsB = endpointHeldGaps(group, (o) => ({ x: o.bx, y: o.by }), holdStart, detachThreshold);
867+
if (!gapsA || !gapsB) return null;
868+
const pick = danglingEndpoint(last, gapsA, gapsB);
869+
if (!pick || isGaugeIndicator(pick.anchored, pick.dangling, last.nodes)) return null;
870+
return connectorDetachFinding(selector, pick.dangling, pick.gap, last.time);
871+
}
872+
806873
export function detectConnectorMotionDetached(
807874
frames: ConnectorFrame[],
808875
canvas: Canvas,
@@ -816,24 +883,8 @@ export function detectConnectorMotionDetached(
816883
CONNECTOR_DETACH_VIEWPORT_FRACTION * Math.min(canvas.width, canvas.height),
817884
);
818885
for (const [selector, group] of groupConnectorsBySelector(frames)) {
819-
if (group.length < CONNECTOR_MIN_FRAMES) continue;
820-
const last = group[group.length - 1];
821-
if (!last) continue;
822-
const pickA = (o: ConnectorObservation) => ({ x: o.ax, y: o.ay });
823-
const pickB = (o: ConnectorObservation) => ({ x: o.bx, y: o.by });
824-
const gapsA = endpointHeldGaps(group, pickA, holdStart, detachThreshold);
825-
const gapsB = endpointHeldGaps(group, pickB, holdStart, detachThreshold);
826-
if (!gapsA || !gapsB) continue;
827-
// One endpoint anchored to a node, the other persistently in empty space.
828-
if (isAnchored(gapsA) && isDangling(gapsB) && !isAnchored(gapsB)) {
829-
findings.push(
830-
connectorDetachFinding(selector, { x: last.bx, y: last.by }, gapsB.danglingGap, last.time),
831-
);
832-
} else if (isAnchored(gapsB) && isDangling(gapsA) && !isAnchored(gapsA)) {
833-
findings.push(
834-
connectorDetachFinding(selector, { x: last.ax, y: last.ay }, gapsA.danglingGap, last.time),
835-
);
836-
}
886+
const finding = connectorGroupFinding(selector, group, holdStart, detachThreshold);
887+
if (finding) findings.push(finding);
837888
}
838889
return findings;
839890
}

0 commit comments

Comments
 (0)