@stacknyu/pi-glitch-lab 0.0.0-stage → 0.5.1
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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/examples/glitchy-app/README.md +14 -0
- package/examples/glitchy-app/THIRD_PARTY_NOTICES.md +27 -0
- package/examples/glitchy-app/app.jsx +62 -0
- package/examples/glitchy-app/public/api/credits.json +1 -0
- package/examples/glitchy-app/public/api/me.json +1 -0
- package/examples/glitchy-app/public/api/messages.json +1 -0
- package/examples/glitchy-app/public/app.js +14749 -0
- package/examples/glitchy-app/public/index.html +2 -0
- package/examples/glitchy-app/serve.mjs +12 -0
- package/extensions/index.ts +157 -0
- package/package.json +53 -3
- package/scripts/affordance_scan.mjs +235 -0
- package/scripts/doctor.mjs +14 -0
- package/scripts/glitch/analyze.mjs +274 -0
- package/scripts/glitch_lab.mjs +519 -0
- package/scripts/interactions.mjs +172 -0
- package/scripts/journey_runner.mjs +260 -0
- package/scripts/lib.mjs +260 -0
- package/scripts/report.mjs +144 -0
- package/scripts/sensors/dom_observer.mjs +96 -0
- package/scripts/sensors/react_sensor.mjs +167 -0
- package/skills/ai-agentic-usability-testing/SKILL.md +68 -0
- package/skills/ai-agentic-usability-testing/assets/default-personas.md +96 -0
- package/skills/ai-agentic-usability-testing/assets/findings.example.json +56 -0
- package/skills/ai-agentic-usability-testing/assets/matrix.default.json +746 -0
- package/skills/ai-agentic-usability-testing/assets/matrix.partners.example.json +73 -0
- package/skills/ai-agentic-usability-testing/assets/matrix.web.example.json +32 -0
- package/skills/ai-agentic-usability-testing/assets/personas-template.md +45 -0
- package/skills/ai-agentic-usability-testing/references/latency-reliability.md +41 -0
- package/skills/ai-agentic-usability-testing/references/regression-pipeline.md +41 -0
- package/skills/ai-agentic-usability-testing/references/tenant-persona-matrix.md +61 -0
- package/skills/ai-agentic-usability-testing/references/visual-affordance-guide.md +97 -0
- package/skills/ai-agentic-usability-testing/references/visual-glitches.md +83 -0
- package/skills/glitch-hunt/SKILL.md +59 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// ---------------- analysis helpers (run inside a blank helper page) ----------------
|
|
5
|
+
const SUB = 4; // each grid block is compared as SUB×SUB sub-cells so small text changes register
|
|
6
|
+
export async function blockSignatures(helper, frames, cols) {
|
|
7
|
+
return helper.evaluate(async ({ frames, cols, SUB }) => {
|
|
8
|
+
const load = (src) => new Promise((r) => { const i = new Image(); i.onload = () => r(i); i.onerror = () => r(null); i.src = src; });
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const f of frames) {
|
|
11
|
+
const im = await load("data:image/png;base64," + f.data);
|
|
12
|
+
if (!im) { out.push(null); continue; }
|
|
13
|
+
const rows = Math.max(1, Math.round((cols * im.height) / im.width));
|
|
14
|
+
const W = cols * SUB, H = rows * SUB;
|
|
15
|
+
const c = new OffscreenCanvas(W, H), x = c.getContext("2d");
|
|
16
|
+
x.imageSmoothingQuality = "high"; x.drawImage(im, 0, 0, W, H);
|
|
17
|
+
out.push({ w: im.width, h: im.height, rows, px: Array.from(x.getImageData(0, 0, W, H).data) });
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}, { frames, cols, SUB });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function composite(helper, panels, file, opts = {}) {
|
|
24
|
+
// panels: [{data, label, boxes:[{x,y,w,h}] (in image px)}]
|
|
25
|
+
const png = await helper.evaluate(async ({ panels, opts }) => {
|
|
26
|
+
const load = (src) => new Promise((r) => { const i = new Image(); i.onload = () => r(i); i.src = src; });
|
|
27
|
+
const ims = await Promise.all(panels.map((p) => load("data:image/png;base64," + p.data)));
|
|
28
|
+
const tw = opts.thumbW || 360, scale = tw / ims[0].width, th = Math.round(ims[0].height * scale);
|
|
29
|
+
const per = opts.perRow || panels.length, rows = Math.ceil(panels.length / per);
|
|
30
|
+
const c = new OffscreenCanvas(per * (tw + 12) + 12, rows * (th + 34) + 12), x = c.getContext("2d");
|
|
31
|
+
x.fillStyle = "#fff"; x.fillRect(0, 0, c.width, c.height);
|
|
32
|
+
panels.forEach((p, i) => {
|
|
33
|
+
const cx = 12 + (i % per) * (tw + 12), cy = 12 + Math.floor(i / per) * (th + 34);
|
|
34
|
+
x.fillStyle = "#111"; x.font = "bold 14px sans-serif"; x.fillText(p.label, cx, cy + 14);
|
|
35
|
+
x.drawImage(ims[i], cx, cy + 22, tw, th);
|
|
36
|
+
x.strokeStyle = "#ccc"; x.lineWidth = 1; x.strokeRect(cx, cy + 22, tw, th);
|
|
37
|
+
x.strokeStyle = "#e11d48"; x.lineWidth = 3;
|
|
38
|
+
for (const b of p.boxes || []) x.strokeRect(cx + b.x * scale, cy + 22 + b.y * scale, b.w * scale, b.h * scale);
|
|
39
|
+
});
|
|
40
|
+
const blob = await c.convertToBlob({ type: "image/png" });
|
|
41
|
+
const buf = new Uint8Array(await blob.arrayBuffer());
|
|
42
|
+
let s = ""; for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000));
|
|
43
|
+
return btoa(s);
|
|
44
|
+
}, { panels, opts });
|
|
45
|
+
fs.writeFileSync(file, Buffer.from(png, "base64"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const px = (a, b, i) => Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
|
|
49
|
+
/** Max sub-cell difference inside grid block `blk` (block index), for frames with `cols` blocks per row. */
|
|
50
|
+
export function blockDiff(A, B, blk, cols) {
|
|
51
|
+
const r = Math.floor(blk / cols), c = blk % cols, W = cols * SUB;
|
|
52
|
+
let m = 0;
|
|
53
|
+
for (let dy = 0; dy < SUB; dy++) for (let dx = 0; dx < SUB; dx++) {
|
|
54
|
+
const i = ((r * SUB + dy) * W + (c * SUB + dx)) * 4;
|
|
55
|
+
const d = px(A.px, B.px, i); if (d > m) m = d;
|
|
56
|
+
}
|
|
57
|
+
return m;
|
|
58
|
+
}
|
|
59
|
+
/** fraction of the smaller rect covered by the intersection */
|
|
60
|
+
export const overlapFrac = (a, b) => {
|
|
61
|
+
if (!a || !b) return 0;
|
|
62
|
+
const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x), h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
63
|
+
return w > 0 && h > 0 ? (w * h) / Math.max(1, Math.min(a.w * a.h, b.w * b.h)) : 0;
|
|
64
|
+
};
|
|
65
|
+
export const overlaps = (a, b) => a && b && a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
66
|
+
|
|
67
|
+
export function groupBlocks(blocks, cols) {
|
|
68
|
+
// blocks: Set of indices -> connected components -> bounding boxes in grid units
|
|
69
|
+
const seen = new Set(), comps = [];
|
|
70
|
+
for (const b of blocks) {
|
|
71
|
+
if (seen.has(b)) continue;
|
|
72
|
+
const stack = [b], comp = []; seen.add(b);
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
const k = stack.pop(); comp.push(k);
|
|
75
|
+
const r = Math.floor(k / cols), c = k % cols;
|
|
76
|
+
for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]) {
|
|
77
|
+
const n = (r + dr) * cols + (c + dc);
|
|
78
|
+
if (c + dc >= 0 && c + dc < cols && blocks.has(n) && !seen.has(n)) { seen.add(n); stack.push(n); }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const rs = comp.map((k) => Math.floor(k / cols)), cs = comp.map((k) => k % cols);
|
|
82
|
+
comps.push({ r0: Math.min(...rs), r1: Math.max(...rs), c0: Math.min(...cs), c1: Math.max(...cs), n: comp.length });
|
|
83
|
+
}
|
|
84
|
+
return comps;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function analyzeScenario({ helper, frames, dom, viewport, ignoreRects, targetRect, kind, dir, name, grid = 24 }) {
|
|
88
|
+
const GRID = grid;
|
|
89
|
+
const res = { scenario: name, kind, frames: frames.length, glitches: [] };
|
|
90
|
+
if (frames.length < 3) { res.note = "too few painted frames to analyze"; }
|
|
91
|
+
if (process.env.GLITCH_DEBUG) frames.forEach((f, i) => fs.writeFileSync(path.join(dir, `${name}_raw_${String(i).padStart(2, "0")}_${f.t}ms.png`), Buffer.from(f.data, "base64")));
|
|
92
|
+
const sigs = frames.length ? await blockSignatures(helper, frames, GRID) : [];
|
|
93
|
+
const valid = sigs.map((s, i) => (s ? i : -1)).filter((i) => i >= 0);
|
|
94
|
+
const first = sigs[valid[0]];
|
|
95
|
+
const T = (i) => frames[i].t;
|
|
96
|
+
const imgToCss = first ? viewport.width / first.w : 1;
|
|
97
|
+
const blockRect = (g) => ({ x: Math.round((g.c0 * first.w) / GRID), y: Math.round((g.r0 * first.h) / first.rows),
|
|
98
|
+
w: Math.round(((g.c1 - g.c0 + 1) * first.w) / GRID), h: Math.round(((g.r1 - g.r0 + 1) * first.h) / first.rows) });
|
|
99
|
+
const toCss = (r) => ({ x: Math.round(r.x * imgToCss), y: Math.round(r.y * imgToCss), w: Math.round(r.w * imgToCss), h: Math.round(r.h * imgToCss) });
|
|
100
|
+
const ignored = (cssR) => ignoreRects.some((ir) => overlaps(ir, cssR));
|
|
101
|
+
const TH = 90; // per-pixel RGB sum difference for a sub-cell to count as changed
|
|
102
|
+
|
|
103
|
+
if (first && valid.length >= 3) {
|
|
104
|
+
const nB = GRID * first.rows;
|
|
105
|
+
// --- flicker: per block, a change followed by a change back within 600ms (A -> B -> A)
|
|
106
|
+
const flickerEvents = [];
|
|
107
|
+
const groups = new Map(); // "i,j,k" -> Set(blocks)
|
|
108
|
+
for (let b = 0; b < nB; b++) {
|
|
109
|
+
const changes = []; // valid-list positions where this block changed vs previous frame
|
|
110
|
+
for (let v = 1; v < valid.length; v++) {
|
|
111
|
+
const P = sigs[valid[v - 1]], Q = sigs[valid[v]];
|
|
112
|
+
if (P.rows === Q.rows && blockDiff(P, Q, b, GRID) > TH) changes.push(v);
|
|
113
|
+
}
|
|
114
|
+
for (let c = 0; c + 1 < changes.length; c++) {
|
|
115
|
+
const vj = changes[c], vk = changes[c + 1];
|
|
116
|
+
const i = valid[vj - 1], j = valid[vj], k = valid[vk];
|
|
117
|
+
if (T(k) - T(j) > 600 || (kind === "load" && vj - 1 === 0)) continue; // B must be brief; never compare against the very first (often blank) paint
|
|
118
|
+
if (blockDiff(sigs[i], sigs[k], b, GRID) < 30) {
|
|
119
|
+
const key = `${i},${j},${k}`;
|
|
120
|
+
if (!groups.has(key)) groups.set(key, new Set());
|
|
121
|
+
groups.get(key).add(b);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const [key, set] of groups) {
|
|
126
|
+
const [i, j, k] = key.split(",").map(Number);
|
|
127
|
+
for (const g of groupBlocks(set, GRID)) {
|
|
128
|
+
const r = blockRect(g), css = toCss(r);
|
|
129
|
+
if (ignored(css)) continue;
|
|
130
|
+
flickerEvents.push({ type: "flicker", t: T(j), visibleMs: T(k) - T(j), before: T(i), back: T(k), img: r, css, frames: [i, j, k], areaPct: +((100 * g.n) / nB).toFixed(1) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
flickerEvents.sort((x, y) => x.t - y.t);
|
|
134
|
+
res.glitches.push(...flickerEvents);
|
|
135
|
+
|
|
136
|
+
// --- settle time & late changes (page loads only)
|
|
137
|
+
let settleIdx = null;
|
|
138
|
+
for (let vi = 1; vi < valid.length; vi++) {
|
|
139
|
+
const prevT = T(valid[vi - 1]), curT = T(valid[vi]);
|
|
140
|
+
if (curT - prevT >= 1000 && T(valid[vi - 1]) > 300) { settleIdx = valid[vi - 1]; break; }
|
|
141
|
+
}
|
|
142
|
+
res.settledAtMs = settleIdx !== null ? T(settleIdx) : null;
|
|
143
|
+
if (kind === "load" && settleIdx !== null) {
|
|
144
|
+
let base = settleIdx;
|
|
145
|
+
for (const i of valid.filter((i) => i > settleIdx)) {
|
|
146
|
+
const set = new Set();
|
|
147
|
+
for (let b = 0; b < nB; b++) if (blockDiff(sigs[base], sigs[i], b, GRID) > TH) set.add(b);
|
|
148
|
+
for (const g of groupBlocks(set, GRID)) {
|
|
149
|
+
const r = blockRect(g), css = toCss(r);
|
|
150
|
+
if (ignored(css) || g.n < 2) continue;
|
|
151
|
+
res.glitches.push({ type: "late-change", t: T(i), afterSettleMs: T(i) - T(settleIdx), img: r, css, frames: [base, i], areaPct: +((100 * g.n) / nB).toFixed(1) });
|
|
152
|
+
}
|
|
153
|
+
base = i;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// --- interactions: changes far from what the user touched, beyond the main update
|
|
157
|
+
if (kind === "interaction" && targetRect) {
|
|
158
|
+
const last = valid[valid.length - 1];
|
|
159
|
+
for (const i of valid.slice(1)) if (T(i) > 1500) {
|
|
160
|
+
const prev = valid[valid.indexOf(i) - 1];
|
|
161
|
+
const set = new Set();
|
|
162
|
+
for (let b = 0; b < nB; b++) if (blockDiff(sigs[prev], sigs[i], b, GRID) > TH) set.add(b);
|
|
163
|
+
for (const g of groupBlocks(set, GRID)) {
|
|
164
|
+
const r = blockRect(g), css = toCss(r);
|
|
165
|
+
if (ignored(css) || g.n < 2 || overlaps(css, targetRect)) continue;
|
|
166
|
+
res.glitches.push({ type: "late-change", t: T(i), note: ">1.5s after the interaction, away from the touched element", img: r, css, frames: [prev, i], areaPct: +((100 * g.n) / nB).toFixed(1) });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// label glitches inside elements that run CSS/Web animations as probably intentional
|
|
173
|
+
for (const g of res.glitches) {
|
|
174
|
+
const a = (dom.anim || []).find((r) => g.css && Math.abs(r.t - g.t) <= 300 &&
|
|
175
|
+
Math.max(0, Math.min(r.x + r.w, g.css.x + g.css.w) - Math.max(r.x, g.css.x)) *
|
|
176
|
+
Math.max(0, Math.min(r.y + r.h, g.css.y + g.css.h) - Math.max(r.y, g.css.y)) >= 0.8 * g.css.w * g.css.h);
|
|
177
|
+
if (a) { g.probablyIntentional = true; g.note = ((g.note ? g.note + "; " : "") + `inside an animated element (${a.desc})`); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// a late change that is really one half of a flicker is reported once, as the flicker
|
|
181
|
+
const fl = res.glitches.filter((g) => g.type === "flicker");
|
|
182
|
+
res.glitches = res.glitches.filter((g) => g.type !== "late-change" || !fl.some((f) => overlaps(f.css, g.css) && g.t >= f.t - 50 && g.t <= f.back + 50));
|
|
183
|
+
fl.forEach((f) => { if (f.t > (res.settledAtMs ?? Infinity)) f.note = "happened after the page looked finished"; });
|
|
184
|
+
|
|
185
|
+
// --- DOM evidence
|
|
186
|
+
for (const e of dom.events || []) {
|
|
187
|
+
if (e.rect && ignored(e.rect)) continue;
|
|
188
|
+
res.glitches.push({ ...e, css: e.rect });
|
|
189
|
+
}
|
|
190
|
+
const shifts = (dom.shifts || []).filter((s) => s.value >= 0.005);
|
|
191
|
+
res.cls = +(dom.shifts || []).reduce((a, s) => a + s.value, 0).toFixed(3);
|
|
192
|
+
for (const s of shifts) res.glitches.push({ type: "layout-shift", t: s.t, value: s.value, sources: s.sources, css: s.sources?.[0]?.to });
|
|
193
|
+
|
|
194
|
+
// attach likely DOM culprit to visual glitches
|
|
195
|
+
for (const g of res.glitches.filter((g) => g.type === "flicker" || g.type === "late-change")) {
|
|
196
|
+
const cands = (dom.events || []).filter((e) => e.rect && overlapFrac(e.rect, g.css) > 0.2).map((e) => `${e.type}: ${e.desc}`);
|
|
197
|
+
if (cands.length) g.likelyElements = [...new Set(cands)].slice(0, 3);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// images
|
|
201
|
+
if (frames.length) {
|
|
202
|
+
const step = Math.max(1, Math.ceil(frames.length / 24));
|
|
203
|
+
const film = frames.filter((_, i) => i % step === 0).slice(0, 24).map((f) => ({ data: f.data, label: `${f.t} ms` }));
|
|
204
|
+
await composite(helper, film, path.join(dir, `${name}_filmstrip.png`), { thumbW: 220, perRow: 6 });
|
|
205
|
+
let n = 0;
|
|
206
|
+
for (const g of res.glitches) {
|
|
207
|
+
if (!g.frames && valid.length) {
|
|
208
|
+
const at = (t) => valid.reduce((best, i) => Math.abs(frames[i].t - t) < Math.abs(frames[best].t - t) ? i : best, valid[0]);
|
|
209
|
+
g.frames = [at(g.t - (g.visibleMs || g.gapMs || 0) - 50), at(g.t + 50)];
|
|
210
|
+
g.note = [g.note, "Sampled surrounding frames; the transient itself may not have been captured"].filter(Boolean).join("; ");
|
|
211
|
+
if (g.css) g.img = { x: g.css.x / imgToCss, y: g.css.y / imgToCss, w: g.css.w / imgToCss, h: g.css.h / imgToCss };
|
|
212
|
+
}
|
|
213
|
+
if (!g.frames) continue;
|
|
214
|
+
n++;
|
|
215
|
+
const labels = g.type === "flicker" ? ["before", "glitch", "after"] : ["before", "after"];
|
|
216
|
+
const panels = g.frames.map((fi, j) => ({ data: frames[fi].data, label: `${labels[j]} · ${frames[fi].t} ms`, boxes: g.img ? [g.img] : [] }));
|
|
217
|
+
g.image = `${name}_glitch_${String(n).padStart(2, "0")}.png`;
|
|
218
|
+
await composite(helper, panels, path.join(dir, g.image), { thumbW: 380 });
|
|
219
|
+
delete g.frames; delete g.img;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
Object.defineProperty(res, "_sigs", { value: sigs, enumerable: false });
|
|
223
|
+
Object.defineProperty(res, "_valid", { value: valid, enumerable: false });
|
|
224
|
+
res.imgToCss = +imgToCss.toFixed(4);
|
|
225
|
+
res.counts = res.glitches.reduce((a, g) => { const k = g.probablyIntentional ? g.type + "(animated)" : g.type; a[k] = (a[k] || 0) + 1; return a; }, {});
|
|
226
|
+
return res;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function record(page, fn, watchMs) {
|
|
230
|
+
const cdp = await page.context().newCDPSession(page);
|
|
231
|
+
const frames = [];
|
|
232
|
+
cdp.on("Page.screencastFrame", async (f) => {
|
|
233
|
+
const ts = f.metadata.timestamp * 1000;
|
|
234
|
+
frames.push({ data: f.data, ts, wall: Date.now() });
|
|
235
|
+
cdp.send("Page.screencastFrameAck", { sessionId: f.sessionId }).catch(() => {});
|
|
236
|
+
});
|
|
237
|
+
const vp = page.viewportSize();
|
|
238
|
+
await cdp.send("Page.startScreencast", { format: "png", maxWidth: Math.min(vp.width, 900), maxHeight: 2000, everyNthFrame: 1 });
|
|
239
|
+
const startWall = Date.now();
|
|
240
|
+
try {
|
|
241
|
+
await fn();
|
|
242
|
+
await page.waitForTimeout(watchMs);
|
|
243
|
+
} finally {
|
|
244
|
+
await cdp.send("Page.stopScreencast").catch(() => {});
|
|
245
|
+
await cdp.detach().catch(() => {});
|
|
246
|
+
}
|
|
247
|
+
// frames can arrive out of order: sort by capture timestamp, then re-base on action start
|
|
248
|
+
frames.sort((a, b) => a.ts - b.ts);
|
|
249
|
+
if (frames.length) {
|
|
250
|
+
const tsMin = frames[0].ts, wallMin = Math.min(...frames.map((f) => f.wall));
|
|
251
|
+
const off = wallMin - startWall - (frames.find((f) => f.wall === wallMin).ts - tsMin);
|
|
252
|
+
frames.forEach((f) => (f.t = Math.max(0, Math.round(f.ts - tsMin + off))));
|
|
253
|
+
}
|
|
254
|
+
return frames;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function ignoreRectsFor(page, cell) {
|
|
258
|
+
if (!cell.mask?.length) return [];
|
|
259
|
+
return page.evaluate((sels) => sels.flatMap((s) => { try { return [...document.querySelectorAll(s)]; } catch { return []; } })
|
|
260
|
+
.map((e) => { const r = e.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }), cell.mask).catch(() => []);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Fraction (0..1) of grid blocks inside cssRect that differ between two analyzed frames (by index). */
|
|
264
|
+
export function regionChange(res, iA, iB, cssRect, grid = 24) {
|
|
265
|
+
const sigs = res._sigs; if (!sigs || !sigs[iA] || !sigs[iB] || !cssRect) return null;
|
|
266
|
+
const A = sigs[iA], B = sigs[iB]; if (A.rows !== B.rows) return null;
|
|
267
|
+
const k = 1 / (res.imgToCss || 1);
|
|
268
|
+
const bw = A.w / grid, bh = A.h / A.rows;
|
|
269
|
+
const c0 = Math.max(0, Math.floor((cssRect.x * k) / bw)), c1 = Math.min(grid - 1, Math.floor(((cssRect.x + cssRect.w) * k - 1) / bw));
|
|
270
|
+
const r0 = Math.max(0, Math.floor((cssRect.y * k) / bh)), r1 = Math.min(A.rows - 1, Math.floor(((cssRect.y + cssRect.h) * k - 1) / bh));
|
|
271
|
+
let n = 0, ch = 0;
|
|
272
|
+
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) { n++; if (blockDiff(A, B, r * grid + c, grid) > 90) ch++; }
|
|
273
|
+
return n ? ch / n : null;
|
|
274
|
+
}
|