@michaelmagan/dnumb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- package/skills/dnumb/SKILL.md +136 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1-D numeric clustering and spacing-grid detection — pure arithmetic over
|
|
3
|
+
* plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
|
|
4
|
+
* caller decides whether the numbers are x-coordinates, gaps, or anything
|
|
5
|
+
* else). Same boundary rules as the rest of `src/analysis/`: no imports
|
|
6
|
+
* beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
|
|
7
|
+
* needs neither, so it has no imports.
|
|
8
|
+
*/
|
|
9
|
+
export interface EdgeCluster {
|
|
10
|
+
/** Representative value for the cluster: the mean of its members: cheap,
|
|
11
|
+
* stable under this module's adjacent-gap grouping (a cluster's members
|
|
12
|
+
* are already known to be within `tolerance` px of their neighbors, so
|
|
13
|
+
* the mean can never land more than `tolerance` px from any one member),
|
|
14
|
+
* and doesn't require picking a media convention for even-sized groups. */
|
|
15
|
+
value: number;
|
|
16
|
+
count: number;
|
|
17
|
+
/** Original indices into the input `values` array that belong to this
|
|
18
|
+
* cluster, in the order they were grouped (ascending by value, not
|
|
19
|
+
* necessarily the input order). */
|
|
20
|
+
members: number[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Groups `values` into 1-D clusters using adjacent-gap chaining: sort
|
|
24
|
+
* ascending, then start a new cluster whenever the gap between two
|
|
25
|
+
* consecutive sorted values exceeds `tolerance`. This is deliberately
|
|
26
|
+
* "adjacent" rather than "each within `tolerance` of the cluster's running
|
|
27
|
+
* mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
|
|
28
|
+
* under either rule, but the two rules diverge once a cluster's span grows
|
|
29
|
+
* past `tolerance`; adjacent-gap chaining is the simpler rule to reason
|
|
30
|
+
* about and to test, and matches "group any within tolerance px of each
|
|
31
|
+
* other" read as a chain of pairwise closeness rather than a fixed-radius
|
|
32
|
+
* ball around one center.
|
|
33
|
+
*/
|
|
34
|
+
export declare function clusterEdges(values: number[], tolerance?: number): Array<{
|
|
35
|
+
value: number;
|
|
36
|
+
count: number;
|
|
37
|
+
members: number[];
|
|
38
|
+
}>;
|
|
39
|
+
export interface BaseUnitResult {
|
|
40
|
+
unit: number | null;
|
|
41
|
+
onGridShare: number;
|
|
42
|
+
offGrid: number[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Picks the candidate spacing unit (default `[4, 8]`) that best explains
|
|
46
|
+
* `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
|
|
47
|
+
* multiple of that unit. Zero-valued gaps are excluded from both the sample
|
|
48
|
+
* count and the coverage fraction — a touching pair (gap 0) is trivially "on
|
|
49
|
+
* every grid" and would inflate the score without saying anything about the
|
|
50
|
+
* page's actual spacing rhythm.
|
|
51
|
+
*
|
|
52
|
+
* Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
|
|
53
|
+
* `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
|
|
54
|
+
* `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
|
|
55
|
+
* `gaps` — a caller with too little data to trust gets everything back as
|
|
56
|
+
* "off-grid" rather than a partially-filtered list) instead of a
|
|
57
|
+
* low-confidence guess. Ties are broken toward the smaller candidate — the
|
|
58
|
+
* candidates are tried in the order given and a later candidate only wins by
|
|
59
|
+
* strictly exceeding the current best share.
|
|
60
|
+
*/
|
|
61
|
+
export declare function detectBaseUnit(gaps: number[], candidates?: number[]): BaseUnitResult;
|
|
62
|
+
export interface SpacingStats {
|
|
63
|
+
distinct: Record<string, number>;
|
|
64
|
+
}
|
|
65
|
+
/** Histogram of `gaps`, each value rounded to the nearest px before being
|
|
66
|
+
* used as the key — the digest and the (future) spacing check use this to
|
|
67
|
+
* summarize "column gaps: 24×4" rather than listing every raw float. */
|
|
68
|
+
export declare function spacingStats(gaps: number[]): SpacingStats;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1-D numeric clustering and spacing-grid detection — pure arithmetic over
|
|
3
|
+
* plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
|
|
4
|
+
* caller decides whether the numbers are x-coordinates, gaps, or anything
|
|
5
|
+
* else). Same boundary rules as the rest of `src/analysis/`: no imports
|
|
6
|
+
* beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
|
|
7
|
+
* needs neither, so it has no imports.
|
|
8
|
+
*/
|
|
9
|
+
export interface EdgeCluster {
|
|
10
|
+
/** Representative value for the cluster: the mean of its members: cheap,
|
|
11
|
+
* stable under this module's adjacent-gap grouping (a cluster's members
|
|
12
|
+
* are already known to be within `tolerance` px of their neighbors, so
|
|
13
|
+
* the mean can never land more than `tolerance` px from any one member),
|
|
14
|
+
* and doesn't require picking a media convention for even-sized groups. */
|
|
15
|
+
value: number;
|
|
16
|
+
count: number;
|
|
17
|
+
/** Original indices into the input `values` array that belong to this
|
|
18
|
+
* cluster, in the order they were grouped (ascending by value, not
|
|
19
|
+
* necessarily the input order). */
|
|
20
|
+
members: number[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Groups `values` into 1-D clusters using adjacent-gap chaining: sort
|
|
24
|
+
* ascending, then start a new cluster whenever the gap between two
|
|
25
|
+
* consecutive sorted values exceeds `tolerance`. This is deliberately
|
|
26
|
+
* "adjacent" rather than "each within `tolerance` of the cluster's running
|
|
27
|
+
* mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
|
|
28
|
+
* under either rule, but the two rules diverge once a cluster's span grows
|
|
29
|
+
* past `tolerance`; adjacent-gap chaining is the simpler rule to reason
|
|
30
|
+
* about and to test, and matches "group any within tolerance px of each
|
|
31
|
+
* other" read as a chain of pairwise closeness rather than a fixed-radius
|
|
32
|
+
* ball around one center.
|
|
33
|
+
*/
|
|
34
|
+
export declare function clusterEdges(values: number[], tolerance?: number): Array<{
|
|
35
|
+
value: number;
|
|
36
|
+
count: number;
|
|
37
|
+
members: number[];
|
|
38
|
+
}>;
|
|
39
|
+
export interface BaseUnitResult {
|
|
40
|
+
unit: number | null;
|
|
41
|
+
onGridShare: number;
|
|
42
|
+
offGrid: number[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Picks the candidate spacing unit (default `[4, 8]`) that best explains
|
|
46
|
+
* `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
|
|
47
|
+
* multiple of that unit. Zero-valued gaps are excluded from both the sample
|
|
48
|
+
* count and the coverage fraction — a touching pair (gap 0) is trivially "on
|
|
49
|
+
* every grid" and would inflate the score without saying anything about the
|
|
50
|
+
* page's actual spacing rhythm.
|
|
51
|
+
*
|
|
52
|
+
* Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
|
|
53
|
+
* `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
|
|
54
|
+
* `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
|
|
55
|
+
* `gaps` — a caller with too little data to trust gets everything back as
|
|
56
|
+
* "off-grid" rather than a partially-filtered list) instead of a
|
|
57
|
+
* low-confidence guess. Ties are broken toward the smaller candidate — the
|
|
58
|
+
* candidates are tried in the order given and a later candidate only wins by
|
|
59
|
+
* strictly exceeding the current best share.
|
|
60
|
+
*/
|
|
61
|
+
export declare function detectBaseUnit(gaps: number[], candidates?: number[]): BaseUnitResult;
|
|
62
|
+
export interface SpacingStats {
|
|
63
|
+
distinct: Record<string, number>;
|
|
64
|
+
}
|
|
65
|
+
/** Histogram of `gaps`, each value rounded to the nearest px before being
|
|
66
|
+
* used as the key — the digest and the (future) spacing check use this to
|
|
67
|
+
* summarize "column gaps: 24×4" rather than listing every raw float. */
|
|
68
|
+
export declare function spacingStats(gaps: number[]): SpacingStats;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1-D numeric clustering and spacing-grid detection — pure arithmetic over
|
|
3
|
+
* plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
|
|
4
|
+
* caller decides whether the numbers are x-coordinates, gaps, or anything
|
|
5
|
+
* else). Same boundary rules as the rest of `src/analysis/`: no imports
|
|
6
|
+
* beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
|
|
7
|
+
* needs neither, so it has no imports.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Groups `values` into 1-D clusters using adjacent-gap chaining: sort
|
|
11
|
+
* ascending, then start a new cluster whenever the gap between two
|
|
12
|
+
* consecutive sorted values exceeds `tolerance`. This is deliberately
|
|
13
|
+
* "adjacent" rather than "each within `tolerance` of the cluster's running
|
|
14
|
+
* mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
|
|
15
|
+
* under either rule, but the two rules diverge once a cluster's span grows
|
|
16
|
+
* past `tolerance`; adjacent-gap chaining is the simpler rule to reason
|
|
17
|
+
* about and to test, and matches "group any within tolerance px of each
|
|
18
|
+
* other" read as a chain of pairwise closeness rather than a fixed-radius
|
|
19
|
+
* ball around one center.
|
|
20
|
+
*/
|
|
21
|
+
export function clusterEdges(values, tolerance = 2) {
|
|
22
|
+
if (values.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
const sorted = values.map((value, index) => ({ value, index })).sort((a, b) => a.value - b.value);
|
|
25
|
+
const clusters = [];
|
|
26
|
+
let current = [];
|
|
27
|
+
function flush() {
|
|
28
|
+
if (current.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const sum = current.reduce((total, entry) => total + entry.value, 0);
|
|
31
|
+
clusters.push({
|
|
32
|
+
value: sum / current.length,
|
|
33
|
+
count: current.length,
|
|
34
|
+
members: current.map((entry) => entry.index),
|
|
35
|
+
});
|
|
36
|
+
current = [];
|
|
37
|
+
}
|
|
38
|
+
for (const entry of sorted) {
|
|
39
|
+
const prev = current.at(-1);
|
|
40
|
+
if (prev !== undefined && entry.value - prev.value > tolerance)
|
|
41
|
+
flush();
|
|
42
|
+
current.push(entry);
|
|
43
|
+
}
|
|
44
|
+
flush();
|
|
45
|
+
return clusters;
|
|
46
|
+
}
|
|
47
|
+
const MIN_GAP_SAMPLES = 5;
|
|
48
|
+
const MIN_ON_GRID_SHARE = 0.5;
|
|
49
|
+
const GRID_TOLERANCE = 1;
|
|
50
|
+
/**
|
|
51
|
+
* Picks the candidate spacing unit (default `[4, 8]`) that best explains
|
|
52
|
+
* `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
|
|
53
|
+
* multiple of that unit. Zero-valued gaps are excluded from both the sample
|
|
54
|
+
* count and the coverage fraction — a touching pair (gap 0) is trivially "on
|
|
55
|
+
* every grid" and would inflate the score without saying anything about the
|
|
56
|
+
* page's actual spacing rhythm.
|
|
57
|
+
*
|
|
58
|
+
* Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
|
|
59
|
+
* `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
|
|
60
|
+
* `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
|
|
61
|
+
* `gaps` — a caller with too little data to trust gets everything back as
|
|
62
|
+
* "off-grid" rather than a partially-filtered list) instead of a
|
|
63
|
+
* low-confidence guess. Ties are broken toward the smaller candidate — the
|
|
64
|
+
* candidates are tried in the order given and a later candidate only wins by
|
|
65
|
+
* strictly exceeding the current best share.
|
|
66
|
+
*/
|
|
67
|
+
export function detectBaseUnit(gaps, candidates = [4, 8]) {
|
|
68
|
+
const nonZero = gaps.filter((gap) => gap !== 0);
|
|
69
|
+
if (nonZero.length < MIN_GAP_SAMPLES) {
|
|
70
|
+
return { unit: null, onGridShare: 0, offGrid: gaps };
|
|
71
|
+
}
|
|
72
|
+
let bestUnit = null;
|
|
73
|
+
let bestShare = 0;
|
|
74
|
+
let bestOffGrid = gaps;
|
|
75
|
+
for (const unit of candidates) {
|
|
76
|
+
if (unit <= 0)
|
|
77
|
+
continue;
|
|
78
|
+
const offGrid = [];
|
|
79
|
+
let onGrid = 0;
|
|
80
|
+
for (const gap of nonZero) {
|
|
81
|
+
const nearestMultiple = Math.round(gap / unit) * unit;
|
|
82
|
+
if (Math.abs(gap - nearestMultiple) <= GRID_TOLERANCE)
|
|
83
|
+
onGrid++;
|
|
84
|
+
else
|
|
85
|
+
offGrid.push(gap);
|
|
86
|
+
}
|
|
87
|
+
const share = onGrid / nonZero.length;
|
|
88
|
+
if (share > bestShare) {
|
|
89
|
+
bestShare = share;
|
|
90
|
+
bestUnit = unit;
|
|
91
|
+
bestOffGrid = offGrid;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (bestUnit === null || bestShare < MIN_ON_GRID_SHARE) {
|
|
95
|
+
return { unit: null, onGridShare: 0, offGrid: gaps };
|
|
96
|
+
}
|
|
97
|
+
return { unit: bestUnit, onGridShare: bestShare, offGrid: bestOffGrid };
|
|
98
|
+
}
|
|
99
|
+
/** Histogram of `gaps`, each value rounded to the nearest px before being
|
|
100
|
+
* used as the key — the digest and the (future) spacing check use this to
|
|
101
|
+
* summarize "column gaps: 24×4" rather than listing every raw float. */
|
|
102
|
+
export function spacingStats(gaps) {
|
|
103
|
+
const distinct = {};
|
|
104
|
+
for (const gap of gaps) {
|
|
105
|
+
const key = String(Math.round(gap));
|
|
106
|
+
distinct[key] = (distinct[key] ?? 0) + 1;
|
|
107
|
+
}
|
|
108
|
+
return { distinct };
|
|
109
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.distance = distance;
|
|
4
|
+
exports.nearest = nearest;
|
|
5
|
+
exports.proximities = proximities;
|
|
6
|
+
/**
|
|
7
|
+
* Geometric distance queries over a `LayoutNode[]` tree — the piece that
|
|
8
|
+
* lets an agent go from a digest's element ids to on-demand geometric
|
|
9
|
+
* answers (`query`'s `distance`/`nearest` verbs) instead of re-deriving
|
|
10
|
+
* arithmetic by hand from raw rects. Pure, tree-shaped, no `ElementRecord` —
|
|
11
|
+
* same boundary rules as the rest of `src/analysis/`.
|
|
12
|
+
*/
|
|
13
|
+
const geometry_js_1 = require("./geometry.cjs");
|
|
14
|
+
/** Distance between two rects along every axis a query needs: signed
|
|
15
|
+
* per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
|
|
16
|
+
function distance(a, b) {
|
|
17
|
+
const dx = (0, geometry_js_1.gapAlong)(a, b, 'x');
|
|
18
|
+
const dy = (0, geometry_js_1.gapAlong)(a, b, 'y');
|
|
19
|
+
const edge = Math.sqrt(Math.max(dx, 0) ** 2 + Math.max(dy, 0) ** 2);
|
|
20
|
+
const ca = (0, geometry_js_1.center)(a);
|
|
21
|
+
const cb = (0, geometry_js_1.center)(b);
|
|
22
|
+
const centerDistance = Math.sqrt((ca.x - cb.x) ** 2 + (ca.y - cb.y) ** 2);
|
|
23
|
+
return { dx, dy, edge, center: centerDistance };
|
|
24
|
+
}
|
|
25
|
+
/** Flattens the tree once, tracking each node's immediate parent id (for
|
|
26
|
+
* sibling exclusion in `proximities`) and full ancestor-id set (for
|
|
27
|
+
* ancestor/descendant exclusion in both `nearest` and `proximities`) — the
|
|
28
|
+
* same lineage-tracking shape `neighbors.ts#overlaps` uses internally,
|
|
29
|
+
* reimplemented here rather than imported since `neighbors.ts` doesn't
|
|
30
|
+
* export it and this module also needs `parentId`, which that shape
|
|
31
|
+
* doesn't carry. */
|
|
32
|
+
function flattenWithLineage(tree) {
|
|
33
|
+
const entries = [];
|
|
34
|
+
function visit(node, parentId, ancestorIds) {
|
|
35
|
+
entries.push({ node, parentId, ancestorIds });
|
|
36
|
+
const withSelf = new Set(ancestorIds);
|
|
37
|
+
withSelf.add(node.elementId);
|
|
38
|
+
for (const child of node.children)
|
|
39
|
+
visit(child, node.elementId, withSelf);
|
|
40
|
+
}
|
|
41
|
+
for (const root of tree)
|
|
42
|
+
visit(root, null, new Set());
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
function isAncestorPair(a, b) {
|
|
46
|
+
return a.ancestorIds.has(b.node.elementId) || b.ancestorIds.has(a.node.elementId);
|
|
47
|
+
}
|
|
48
|
+
function idCompare(a, b) {
|
|
49
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
50
|
+
}
|
|
51
|
+
/** `fixed`/`sticky` elements are deliberately layered over other content
|
|
52
|
+
* (persistent chrome like Charming's floating feedback launcher) — mirrors
|
|
53
|
+
* `checks/overflow.ts#UNCONTAINED_POSITIONS` and
|
|
54
|
+
* `neighbors.ts#UNCONTAINED_POSITIONS`. A `fixed` element reads as "near"
|
|
55
|
+
* whatever page content sits under it at the moment of capture, which is an
|
|
56
|
+
* artifact of scroll position, not a real proximity relationship between
|
|
57
|
+
* two page elements — the same reasoning that excludes it from `overlaps`
|
|
58
|
+
* applies here. */
|
|
59
|
+
const UNCONTAINED_POSITIONS = new Set(['fixed', 'sticky']);
|
|
60
|
+
const DEFAULT_K = 4;
|
|
61
|
+
/** The `k` layout nodes nearest `id` by edge distance (ascending, ties
|
|
62
|
+
* broken by id for determinism), excluding `id` itself and — by default —
|
|
63
|
+
* its ancestors and descendants. Returns `[]` if `id` isn't found. */
|
|
64
|
+
function nearest(tree, id, k = DEFAULT_K, opts = {}) {
|
|
65
|
+
const excludeAncestors = opts.excludeAncestors ?? true;
|
|
66
|
+
const entries = flattenWithLineage(tree);
|
|
67
|
+
const target = entries.find((e) => e.node.elementId === id);
|
|
68
|
+
if (!target)
|
|
69
|
+
return [];
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.node.elementId === id)
|
|
73
|
+
continue;
|
|
74
|
+
if (excludeAncestors && isAncestorPair(target, entry))
|
|
75
|
+
continue;
|
|
76
|
+
const d = distance(target.node.rect, entry.node.rect);
|
|
77
|
+
results.push({ id: entry.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
|
|
78
|
+
}
|
|
79
|
+
results.sort((x, y) => x.edge - y.edge || idCompare(x.id, y.id));
|
|
80
|
+
return results.slice(0, k);
|
|
81
|
+
}
|
|
82
|
+
const DEFAULT_MAX_EDGE = 4;
|
|
83
|
+
/**
|
|
84
|
+
* Every pair of nodes that are NOT ancestor/descendant of each other AND
|
|
85
|
+
* NOT direct siblings (siblings are already covered by
|
|
86
|
+
* `neighbors.ts#siblingGaps` — this is specifically the cross-container
|
|
87
|
+
* near-touch case, e.g. a badge sitting on the wrong card) AND NOT
|
|
88
|
+
* `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
|
|
89
|
+
* or below `opts.maxEdge` (default 4px).
|
|
90
|
+
*
|
|
91
|
+
* Implementation note: this is a straightforward O(n²) pass over the
|
|
92
|
+
* flattened node list, not the sort-by-x sweep the plan calls "ideal" —
|
|
93
|
+
* post-collapse layout trees are expected to be dozens to low hundreds of
|
|
94
|
+
* nodes (confirmed: the real owner-profile golden collapses 54 raw
|
|
95
|
+
* elements to 47 layout nodes), not thousands, so the quadratic pass is
|
|
96
|
+
* fast enough in practice and simpler to get right within this step's
|
|
97
|
+
* budget. Revisit with a sweep if a future corpus produces trees large
|
|
98
|
+
* enough for this to matter.
|
|
99
|
+
*/
|
|
100
|
+
function proximities(tree, opts = {}) {
|
|
101
|
+
const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
|
|
102
|
+
const entries = flattenWithLineage(tree);
|
|
103
|
+
const found = [];
|
|
104
|
+
for (let i = 0; i < entries.length; i++) {
|
|
105
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
106
|
+
const a = entries[i];
|
|
107
|
+
const b = entries[j];
|
|
108
|
+
if (!a || !b)
|
|
109
|
+
continue;
|
|
110
|
+
if (isAncestorPair(a, b))
|
|
111
|
+
continue;
|
|
112
|
+
if (a.parentId !== null && a.parentId === b.parentId)
|
|
113
|
+
continue; // direct siblings
|
|
114
|
+
if (UNCONTAINED_POSITIONS.has(a.node.position) || UNCONTAINED_POSITIONS.has(b.node.position))
|
|
115
|
+
continue;
|
|
116
|
+
const d = distance(a.node.rect, b.node.rect);
|
|
117
|
+
if (d.edge <= maxEdge) {
|
|
118
|
+
found.push({ a: a.node.elementId, b: b.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
found.sort((x, y) => x.edge - y.edge || idCompare(x.a, y.a) || idCompare(x.b, y.b));
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LayoutNode, ProximityPair, Rect } from "./types.cjs";
|
|
2
|
+
export interface DistanceResult {
|
|
3
|
+
/** Signed x-axis edge gap (`geometry.ts#gapAlong`) — negative means the
|
|
4
|
+
* two rects overlap on the x axis. */
|
|
5
|
+
dx: number;
|
|
6
|
+
/** Signed y-axis edge gap — negative means overlap on the y axis. */
|
|
7
|
+
dy: number;
|
|
8
|
+
/** Euclidean gap between the closest edges, 0 when touching or
|
|
9
|
+
* overlapping on either axis (`dx`/`dy` are clamped to 0 before the
|
|
10
|
+
* Euclidean combination, so a real overlap never reads as a negative
|
|
11
|
+
* "closer than touching" edge distance). */
|
|
12
|
+
edge: number;
|
|
13
|
+
/** Straight center-to-center Euclidean distance — always positive,
|
|
14
|
+
* meaningful even when the two rects overlap (unlike `edge`). */
|
|
15
|
+
center: number;
|
|
16
|
+
}
|
|
17
|
+
/** Distance between two rects along every axis a query needs: signed
|
|
18
|
+
* per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
|
|
19
|
+
export declare function distance(a: Rect, b: Rect): DistanceResult;
|
|
20
|
+
export interface NearestOptions {
|
|
21
|
+
/** A child is always at distance 0 from its parent and is never useful
|
|
22
|
+
* information — excludes both ancestors AND descendants of the target
|
|
23
|
+
* node when true (the default). */
|
|
24
|
+
excludeAncestors?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface NearestResult {
|
|
27
|
+
id: string;
|
|
28
|
+
edge: number;
|
|
29
|
+
dx: number;
|
|
30
|
+
dy: number;
|
|
31
|
+
}
|
|
32
|
+
/** The `k` layout nodes nearest `id` by edge distance (ascending, ties
|
|
33
|
+
* broken by id for determinism), excluding `id` itself and — by default —
|
|
34
|
+
* its ancestors and descendants. Returns `[]` if `id` isn't found. */
|
|
35
|
+
export declare function nearest(tree: LayoutNode[], id: string, k?: number, opts?: NearestOptions): NearestResult[];
|
|
36
|
+
export interface ProximitiesOptions {
|
|
37
|
+
/** Only pairs at or below this edge distance (px) are returned. Default
|
|
38
|
+
* 4 — genuinely close but not necessarily touching. */
|
|
39
|
+
maxEdge?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every pair of nodes that are NOT ancestor/descendant of each other AND
|
|
43
|
+
* NOT direct siblings (siblings are already covered by
|
|
44
|
+
* `neighbors.ts#siblingGaps` — this is specifically the cross-container
|
|
45
|
+
* near-touch case, e.g. a badge sitting on the wrong card) AND NOT
|
|
46
|
+
* `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
|
|
47
|
+
* or below `opts.maxEdge` (default 4px).
|
|
48
|
+
*
|
|
49
|
+
* Implementation note: this is a straightforward O(n²) pass over the
|
|
50
|
+
* flattened node list, not the sort-by-x sweep the plan calls "ideal" —
|
|
51
|
+
* post-collapse layout trees are expected to be dozens to low hundreds of
|
|
52
|
+
* nodes (confirmed: the real owner-profile golden collapses 54 raw
|
|
53
|
+
* elements to 47 layout nodes), not thousands, so the quadratic pass is
|
|
54
|
+
* fast enough in practice and simpler to get right within this step's
|
|
55
|
+
* budget. Revisit with a sweep if a future corpus produces trees large
|
|
56
|
+
* enough for this to matter.
|
|
57
|
+
*/
|
|
58
|
+
export declare function proximities(tree: LayoutNode[], opts?: ProximitiesOptions): ProximityPair[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LayoutNode, ProximityPair, Rect } from "./types.js";
|
|
2
|
+
export interface DistanceResult {
|
|
3
|
+
/** Signed x-axis edge gap (`geometry.ts#gapAlong`) — negative means the
|
|
4
|
+
* two rects overlap on the x axis. */
|
|
5
|
+
dx: number;
|
|
6
|
+
/** Signed y-axis edge gap — negative means overlap on the y axis. */
|
|
7
|
+
dy: number;
|
|
8
|
+
/** Euclidean gap between the closest edges, 0 when touching or
|
|
9
|
+
* overlapping on either axis (`dx`/`dy` are clamped to 0 before the
|
|
10
|
+
* Euclidean combination, so a real overlap never reads as a negative
|
|
11
|
+
* "closer than touching" edge distance). */
|
|
12
|
+
edge: number;
|
|
13
|
+
/** Straight center-to-center Euclidean distance — always positive,
|
|
14
|
+
* meaningful even when the two rects overlap (unlike `edge`). */
|
|
15
|
+
center: number;
|
|
16
|
+
}
|
|
17
|
+
/** Distance between two rects along every axis a query needs: signed
|
|
18
|
+
* per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
|
|
19
|
+
export declare function distance(a: Rect, b: Rect): DistanceResult;
|
|
20
|
+
export interface NearestOptions {
|
|
21
|
+
/** A child is always at distance 0 from its parent and is never useful
|
|
22
|
+
* information — excludes both ancestors AND descendants of the target
|
|
23
|
+
* node when true (the default). */
|
|
24
|
+
excludeAncestors?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface NearestResult {
|
|
27
|
+
id: string;
|
|
28
|
+
edge: number;
|
|
29
|
+
dx: number;
|
|
30
|
+
dy: number;
|
|
31
|
+
}
|
|
32
|
+
/** The `k` layout nodes nearest `id` by edge distance (ascending, ties
|
|
33
|
+
* broken by id for determinism), excluding `id` itself and — by default —
|
|
34
|
+
* its ancestors and descendants. Returns `[]` if `id` isn't found. */
|
|
35
|
+
export declare function nearest(tree: LayoutNode[], id: string, k?: number, opts?: NearestOptions): NearestResult[];
|
|
36
|
+
export interface ProximitiesOptions {
|
|
37
|
+
/** Only pairs at or below this edge distance (px) are returned. Default
|
|
38
|
+
* 4 — genuinely close but not necessarily touching. */
|
|
39
|
+
maxEdge?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every pair of nodes that are NOT ancestor/descendant of each other AND
|
|
43
|
+
* NOT direct siblings (siblings are already covered by
|
|
44
|
+
* `neighbors.ts#siblingGaps` — this is specifically the cross-container
|
|
45
|
+
* near-touch case, e.g. a badge sitting on the wrong card) AND NOT
|
|
46
|
+
* `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
|
|
47
|
+
* or below `opts.maxEdge` (default 4px).
|
|
48
|
+
*
|
|
49
|
+
* Implementation note: this is a straightforward O(n²) pass over the
|
|
50
|
+
* flattened node list, not the sort-by-x sweep the plan calls "ideal" —
|
|
51
|
+
* post-collapse layout trees are expected to be dozens to low hundreds of
|
|
52
|
+
* nodes (confirmed: the real owner-profile golden collapses 54 raw
|
|
53
|
+
* elements to 47 layout nodes), not thousands, so the quadratic pass is
|
|
54
|
+
* fast enough in practice and simpler to get right within this step's
|
|
55
|
+
* budget. Revisit with a sweep if a future corpus produces trees large
|
|
56
|
+
* enough for this to matter.
|
|
57
|
+
*/
|
|
58
|
+
export declare function proximities(tree: LayoutNode[], opts?: ProximitiesOptions): ProximityPair[];
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometric distance queries over a `LayoutNode[]` tree — the piece that
|
|
3
|
+
* lets an agent go from a digest's element ids to on-demand geometric
|
|
4
|
+
* answers (`query`'s `distance`/`nearest` verbs) instead of re-deriving
|
|
5
|
+
* arithmetic by hand from raw rects. Pure, tree-shaped, no `ElementRecord` —
|
|
6
|
+
* same boundary rules as the rest of `src/analysis/`.
|
|
7
|
+
*/
|
|
8
|
+
import { center, gapAlong } from "./geometry.js";
|
|
9
|
+
/** Distance between two rects along every axis a query needs: signed
|
|
10
|
+
* per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
|
|
11
|
+
export function distance(a, b) {
|
|
12
|
+
const dx = gapAlong(a, b, 'x');
|
|
13
|
+
const dy = gapAlong(a, b, 'y');
|
|
14
|
+
const edge = Math.sqrt(Math.max(dx, 0) ** 2 + Math.max(dy, 0) ** 2);
|
|
15
|
+
const ca = center(a);
|
|
16
|
+
const cb = center(b);
|
|
17
|
+
const centerDistance = Math.sqrt((ca.x - cb.x) ** 2 + (ca.y - cb.y) ** 2);
|
|
18
|
+
return { dx, dy, edge, center: centerDistance };
|
|
19
|
+
}
|
|
20
|
+
/** Flattens the tree once, tracking each node's immediate parent id (for
|
|
21
|
+
* sibling exclusion in `proximities`) and full ancestor-id set (for
|
|
22
|
+
* ancestor/descendant exclusion in both `nearest` and `proximities`) — the
|
|
23
|
+
* same lineage-tracking shape `neighbors.ts#overlaps` uses internally,
|
|
24
|
+
* reimplemented here rather than imported since `neighbors.ts` doesn't
|
|
25
|
+
* export it and this module also needs `parentId`, which that shape
|
|
26
|
+
* doesn't carry. */
|
|
27
|
+
function flattenWithLineage(tree) {
|
|
28
|
+
const entries = [];
|
|
29
|
+
function visit(node, parentId, ancestorIds) {
|
|
30
|
+
entries.push({ node, parentId, ancestorIds });
|
|
31
|
+
const withSelf = new Set(ancestorIds);
|
|
32
|
+
withSelf.add(node.elementId);
|
|
33
|
+
for (const child of node.children)
|
|
34
|
+
visit(child, node.elementId, withSelf);
|
|
35
|
+
}
|
|
36
|
+
for (const root of tree)
|
|
37
|
+
visit(root, null, new Set());
|
|
38
|
+
return entries;
|
|
39
|
+
}
|
|
40
|
+
function isAncestorPair(a, b) {
|
|
41
|
+
return a.ancestorIds.has(b.node.elementId) || b.ancestorIds.has(a.node.elementId);
|
|
42
|
+
}
|
|
43
|
+
function idCompare(a, b) {
|
|
44
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
45
|
+
}
|
|
46
|
+
/** `fixed`/`sticky` elements are deliberately layered over other content
|
|
47
|
+
* (persistent chrome like Charming's floating feedback launcher) — mirrors
|
|
48
|
+
* `checks/overflow.ts#UNCONTAINED_POSITIONS` and
|
|
49
|
+
* `neighbors.ts#UNCONTAINED_POSITIONS`. A `fixed` element reads as "near"
|
|
50
|
+
* whatever page content sits under it at the moment of capture, which is an
|
|
51
|
+
* artifact of scroll position, not a real proximity relationship between
|
|
52
|
+
* two page elements — the same reasoning that excludes it from `overlaps`
|
|
53
|
+
* applies here. */
|
|
54
|
+
const UNCONTAINED_POSITIONS = new Set(['fixed', 'sticky']);
|
|
55
|
+
const DEFAULT_K = 4;
|
|
56
|
+
/** The `k` layout nodes nearest `id` by edge distance (ascending, ties
|
|
57
|
+
* broken by id for determinism), excluding `id` itself and — by default —
|
|
58
|
+
* its ancestors and descendants. Returns `[]` if `id` isn't found. */
|
|
59
|
+
export function nearest(tree, id, k = DEFAULT_K, opts = {}) {
|
|
60
|
+
const excludeAncestors = opts.excludeAncestors ?? true;
|
|
61
|
+
const entries = flattenWithLineage(tree);
|
|
62
|
+
const target = entries.find((e) => e.node.elementId === id);
|
|
63
|
+
if (!target)
|
|
64
|
+
return [];
|
|
65
|
+
const results = [];
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.node.elementId === id)
|
|
68
|
+
continue;
|
|
69
|
+
if (excludeAncestors && isAncestorPair(target, entry))
|
|
70
|
+
continue;
|
|
71
|
+
const d = distance(target.node.rect, entry.node.rect);
|
|
72
|
+
results.push({ id: entry.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
|
|
73
|
+
}
|
|
74
|
+
results.sort((x, y) => x.edge - y.edge || idCompare(x.id, y.id));
|
|
75
|
+
return results.slice(0, k);
|
|
76
|
+
}
|
|
77
|
+
const DEFAULT_MAX_EDGE = 4;
|
|
78
|
+
/**
|
|
79
|
+
* Every pair of nodes that are NOT ancestor/descendant of each other AND
|
|
80
|
+
* NOT direct siblings (siblings are already covered by
|
|
81
|
+
* `neighbors.ts#siblingGaps` — this is specifically the cross-container
|
|
82
|
+
* near-touch case, e.g. a badge sitting on the wrong card) AND NOT
|
|
83
|
+
* `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
|
|
84
|
+
* or below `opts.maxEdge` (default 4px).
|
|
85
|
+
*
|
|
86
|
+
* Implementation note: this is a straightforward O(n²) pass over the
|
|
87
|
+
* flattened node list, not the sort-by-x sweep the plan calls "ideal" —
|
|
88
|
+
* post-collapse layout trees are expected to be dozens to low hundreds of
|
|
89
|
+
* nodes (confirmed: the real owner-profile golden collapses 54 raw
|
|
90
|
+
* elements to 47 layout nodes), not thousands, so the quadratic pass is
|
|
91
|
+
* fast enough in practice and simpler to get right within this step's
|
|
92
|
+
* budget. Revisit with a sweep if a future corpus produces trees large
|
|
93
|
+
* enough for this to matter.
|
|
94
|
+
*/
|
|
95
|
+
export function proximities(tree, opts = {}) {
|
|
96
|
+
const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
|
|
97
|
+
const entries = flattenWithLineage(tree);
|
|
98
|
+
const found = [];
|
|
99
|
+
for (let i = 0; i < entries.length; i++) {
|
|
100
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
101
|
+
const a = entries[i];
|
|
102
|
+
const b = entries[j];
|
|
103
|
+
if (!a || !b)
|
|
104
|
+
continue;
|
|
105
|
+
if (isAncestorPair(a, b))
|
|
106
|
+
continue;
|
|
107
|
+
if (a.parentId !== null && a.parentId === b.parentId)
|
|
108
|
+
continue; // direct siblings
|
|
109
|
+
if (UNCONTAINED_POSITIONS.has(a.node.position) || UNCONTAINED_POSITIONS.has(b.node.position))
|
|
110
|
+
continue;
|
|
111
|
+
const d = distance(a.node.rect, b.node.rect);
|
|
112
|
+
if (d.edge <= maxEdge) {
|
|
113
|
+
found.push({ a: a.node.elementId, b: b.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
found.sort((x, y) => x.edge - y.edge || idCompare(x.a, y.a) || idCompare(x.b, y.b));
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.intersect = intersect;
|
|
4
|
+
exports.contains = contains;
|
|
5
|
+
exports.gapAlong = gapAlong;
|
|
6
|
+
exports.area = area;
|
|
7
|
+
exports.center = center;
|
|
8
|
+
/**
|
|
9
|
+
* The overlapping rectangle between `a` and `b`, or `null` if they don't
|
|
10
|
+
* overlap on both axes. Touching edges count as no overlap — the intersection
|
|
11
|
+
* would have zero area, and a zero-area rect is not a thing anything on screen
|
|
12
|
+
* can be inside of.
|
|
13
|
+
*/
|
|
14
|
+
function intersect(a, b) {
|
|
15
|
+
const x = Math.max(a.x, b.x);
|
|
16
|
+
const y = Math.max(a.y, b.y);
|
|
17
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
18
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
19
|
+
const width = right - x;
|
|
20
|
+
const height = bottom - y;
|
|
21
|
+
if (width <= 0 || height <= 0)
|
|
22
|
+
return null;
|
|
23
|
+
return { x, y, width, height };
|
|
24
|
+
}
|
|
25
|
+
/** True if `inner` is fully within `outer`. Edges touching counts as contained. */
|
|
26
|
+
function contains(outer, inner) {
|
|
27
|
+
return (inner.x >= outer.x &&
|
|
28
|
+
inner.y >= outer.y &&
|
|
29
|
+
inner.x + inner.width <= outer.x + outer.width &&
|
|
30
|
+
inner.y + inner.height <= outer.y + outer.height);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Signed edge-to-edge distance between `a` and `b` along one axis — positive is
|
|
34
|
+
* a real gap, negative is how far the two rects overlap along that axis.
|
|
35
|
+
*
|
|
36
|
+
* Unlike `intersect`, this is per-axis: two rects side by side in a row overlap
|
|
37
|
+
* on `y` and have a positive gap on `x`, which is exactly the question a gutter
|
|
38
|
+
* measurement asks.
|
|
39
|
+
*/
|
|
40
|
+
function gapAlong(a, b, axis) {
|
|
41
|
+
const size = axis === 'x' ? 'width' : 'height';
|
|
42
|
+
const aStart = a[axis];
|
|
43
|
+
const aEnd = aStart + a[size];
|
|
44
|
+
const bStart = b[axis];
|
|
45
|
+
const bEnd = bStart + b[size];
|
|
46
|
+
if (bStart >= aEnd)
|
|
47
|
+
return bStart - aEnd;
|
|
48
|
+
if (aStart >= bEnd)
|
|
49
|
+
return aStart - bEnd;
|
|
50
|
+
// Overlapping along this axis: negative distance, magnitude of the overlap.
|
|
51
|
+
return Math.max(aStart, bStart) - Math.min(aEnd, bEnd);
|
|
52
|
+
}
|
|
53
|
+
/** Rect area in px². */
|
|
54
|
+
function area(r) {
|
|
55
|
+
return r.width * r.height;
|
|
56
|
+
}
|
|
57
|
+
/** Rect center point. */
|
|
58
|
+
function center(r) {
|
|
59
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
60
|
+
}
|