flowfig 0.3.0 → 0.4.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/README.md CHANGED
@@ -28,6 +28,31 @@ An agent with the flowfig instructions made faithful, checked diagrams more ofte
28
28
 
29
29
  [How we measured](#how-we-measured).
30
30
 
31
+ ## Use it
32
+
33
+ Set up your repo:
34
+
35
+ ```bash
36
+ npx flowfig init
37
+ ```
38
+
39
+ Then start a new agent session. In Claude Code, run:
40
+
41
+ ```
42
+ /figure how does login work
43
+ ```
44
+
45
+ With any agent, ask in plain words:
46
+
47
+ ```
48
+ draw a diagram of how login works in this repo
49
+ use flowfig to draw the checkout flow as a sequence
50
+ draw the refund process with a lane for each team
51
+ ```
52
+
53
+ The agent writes an SVG and ends its reply with `npx flowfig open <path>`. Run that command to see the animation in your
54
+ browser.
55
+
31
56
  ## One command
32
57
 
33
58
  ```bash
@@ -41,18 +66,7 @@ checks the figure itself with `check --strict` and `verify`, and removes the spe
41
66
  `draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
42
67
  and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
43
68
  `--out` sets the path (it must end in `.svg`), `--model` sets the model, `--max-turns` sets the turn cap (default 40) and
44
- `--json` prints `{ out, reply, cost, session, findings }` for scripts.
45
-
46
- ## Quick start
47
-
48
- ```bash
49
- npx flowfig init
50
- ```
51
-
52
- Then ask your agent: "draw a diagram of how login works in this repo".
53
-
54
- `init` writes the flowfig instructions for the agents that your repo uses. The agent reads the code, writes a spec, runs
55
- `flowfig check`, fixes the faults, and renders the SVG.
69
+ `--json` prints `{ out, reply, cost, session, findings }` for scripts. `--open` opens the figure in the default browser after the check.
56
70
 
57
71
  ## The problem
58
72
 
@@ -95,7 +109,9 @@ Then ask your agent: "draw a diagram of how login works in this repo".
95
109
  - [Three forms](#three-forms)
96
110
  - [The spec](#the-spec)
97
111
  - [Check a figure](#check-a-figure)
112
+ - [Open and share a figure](#open-and-share-a-figure)
98
113
  - [Verify in CI](#verify-in-ci)
114
+ - [For teams](#for-teams)
99
115
  - [MCP server](#mcp-server)
100
116
  - [Use in React](#use-in-react)
101
117
  - [Use from Node](#use-from-node)
@@ -315,6 +331,31 @@ A render runs the same check first. If the check finds an error, the render writ
315
331
  In React, `<Flow check />` runs the same rules on the layout that the browser drew. The player prints each fault with
316
332
  `console.warn`.
317
333
 
334
+ ## Open and share a figure
335
+
336
+ `flowfig open` shows a figure in your default browser. On macOS, an `.svg` file often opens in a text editor or in Xcode. Thus
337
+ `open` writes an HTML page that holds the SVG, and opens that page.
338
+
339
+ ```bash
340
+ npx flowfig open docs/checkout.svg
341
+ npx flowfig open docs/checkout.svg --html checkout.html # also write the page to checkout.html
342
+ npx flowfig docs/checkout.json docs/checkout.svg --open # render, then open
343
+ ```
344
+
345
+ `flowfig gif` writes an animated GIF of the whole loop. A GIF plays in Slack, Notion, X and slide decks, where an SVG
346
+ animation does not play.
347
+
348
+ ```bash
349
+ npx flowfig gif docs/checkout.svg # writes docs/checkout.gif
350
+ npx flowfig gif docs/checkout.svg --step 2 # only step 2: writes docs/checkout-step2.gif
351
+ npx flowfig gif docs/checkout.svg --dark --mp4 # the dark theme, and also docs/checkout.mp4
352
+ ```
353
+
354
+ `gif` needs Chrome, Edge, Chromium or Brave in the standard install path. Set `CHROME_PATH` to use another browser program.
355
+ `--fps` sets the frames per second (1 to 50, default 20). `--scale` sets the pixel scale (default 2). `--mp4` needs `ffmpeg`
356
+ on the PATH. If a GIF is over 10 MB, `gif` prints a warning. Use `--step`, a lower `--fps` or a lower `--scale` to make the
357
+ file smaller.
358
+
318
359
  ## Verify in CI
319
360
 
320
361
  A box, an edge or a hop can name the code it draws: `"source": "src/auth/login.ts#verifyPassword"`. `npx flowfig verify docs/login.svg`
@@ -340,6 +381,21 @@ steps:
340
381
  figures: 'docs/**/*.svg' # default **/*.svg
341
382
  ```
342
383
 
384
+ A state lifecycle marks the first state with `mark: "start"` and each final state with `mark: "end"`.
385
+
386
+ ![An order status lifecycle with a start dot and two end rings](docs/order-status.svg)
387
+
388
+ ## For teams
389
+
390
+ A process is a diagram too. `lanes: true` draws one lane per role and the steps left to right in time order. Each step
391
+ links to a heading in the SOP, so `verify` fails when the SOP changes under the diagram.
392
+
393
+ ![The refund process across Customer, Support and Finance](docs/refund-process.svg)
394
+
395
+ A plan is a figure too. `timeline: true` draws one track per team, with each item as a bar from its `from` date to its `to` date. A milestone has only `from`, and `today` draws the line for now.
396
+
397
+ ![A Q4 roadmap with three tracks, milestones and a today line](docs/roadmap.svg)
398
+
343
399
  ## MCP server
344
400
 
345
401
  `npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
@@ -455,8 +511,9 @@ of the file. A second run also replaces a whole file that `init` wrote. If a who
455
511
 
456
512
  ## What flowfig does not draw
457
513
 
458
- flowfig draws parts, the messages between the parts, and their order. flowfig does not draw class diagrams, ER diagrams, Gantt
459
- charts, timelines with dates, charts of numbers or mind maps. Use Mermaid or a chart library for those.
514
+ flowfig draws parts, the messages between the parts, and their order. flowfig draws a roadmap or a timeline with dates (`timeline: true`),
515
+ and converts a Mermaid `gantt` to it. flowfig does not draw class diagrams, ER diagrams, charts of numbers or mind maps. Use Mermaid
516
+ or a chart library for those.
460
517
 
461
518
  ## How we measured
462
519
 
@@ -0,0 +1,33 @@
1
+ export type Found = {
2
+ path: string | null;
3
+ checked: string[];
4
+ };
5
+ /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
6
+ export declare function findBrowser(o: {
7
+ platform: NodeJS.Platform;
8
+ env: Record<string, string | undefined>;
9
+ exists: (p: string) => boolean;
10
+ }): Found;
11
+ export type Cdp = {
12
+ send(method: string, params?: object, sessionId?: string, timeoutMs?: number): Promise<any>;
13
+ once(event: string, sessionId?: string, timeoutMs?: number): Promise<any>;
14
+ };
15
+ /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
16
+ export declare function launch(path: string, profile: string, timeoutMs?: number): Promise<{
17
+ cdp: Cdp;
18
+ close: () => Promise<void>;
19
+ }>;
20
+ export type Capture = {
21
+ pngs: Buffer[];
22
+ width: number;
23
+ height: number;
24
+ loopMs: number;
25
+ };
26
+ /** Set the page, pause the animations and take one PNG per frame time. */
27
+ export declare function captureFrames(cdp: Cdp, html: string, o: {
28
+ width: number;
29
+ height: number;
30
+ scale: number;
31
+ fps: number;
32
+ dark: boolean;
33
+ }): Promise<Capture>;
@@ -0,0 +1,201 @@
1
+ // The capture browser of `flowfig gif`: find it, start it in headless mode, and talk CDP to it over a pipe. Node only.
2
+ import { spawn } from 'node:child_process';
3
+ import { win32 } from 'node:path';
4
+ const MAC = ['Google Chrome', 'Microsoft Edge', 'Chromium', 'Brave Browser'].map((n) => `/Applications/${n}.app/Contents/MacOS/${n}`);
5
+ const LINUX = [
6
+ '/usr/bin/google-chrome',
7
+ '/usr/bin/google-chrome-stable',
8
+ '/usr/bin/chromium',
9
+ '/usr/bin/chromium-browser',
10
+ '/snap/bin/chromium',
11
+ '/usr/bin/microsoft-edge',
12
+ '/usr/bin/brave-browser',
13
+ '/opt/google/chrome/chrome',
14
+ ];
15
+ const WINDOWS = [
16
+ 'Google\\Chrome\\Application\\chrome.exe',
17
+ 'Microsoft\\Edge\\Application\\msedge.exe',
18
+ 'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
19
+ 'Chromium\\Application\\chrome.exe',
20
+ ];
21
+ /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
22
+ export function findBrowser(o) {
23
+ const { env } = o;
24
+ const paths = env.CHROME_PATH
25
+ ? [env.CHROME_PATH]
26
+ : o.platform === 'darwin'
27
+ ? MAC
28
+ : o.platform === 'win32'
29
+ ? [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].flatMap((dir) => dir ? WINDOWS.map((p) => win32.join(dir, p)) : [])
30
+ : LINUX;
31
+ const checked = [];
32
+ for (const p of paths) {
33
+ checked.push(p);
34
+ if (o.exists(p))
35
+ return { path: p, checked };
36
+ }
37
+ return { path: null, checked };
38
+ }
39
+ /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
40
+ export function launch(path, profile, timeoutMs = 30_000) {
41
+ const args = [
42
+ '--headless=new',
43
+ '--remote-debugging-pipe',
44
+ `--user-data-dir=${profile}`,
45
+ '--no-first-run',
46
+ '--no-default-browser-check',
47
+ '--hide-scrollbars',
48
+ '--mute-audio',
49
+ // The GPU raster gave a different pixel run to run on a rounded corner, and a bigger GIF.
50
+ '--disable-gpu',
51
+ // A transform animation (the timeline playhead) runs on the compositor thread, so a seek can draw a stale x under load.
52
+ '--disable-threaded-animation',
53
+ ];
54
+ // Chrome refuses to start as root without it, for example in a Docker container.
55
+ if (process.getuid?.() === 0)
56
+ args.push('--no-sandbox');
57
+ const child = spawn(path, [...args, 'about:blank'], {
58
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
59
+ // Its own process group, so close can stop the helpers too. Not on Windows: there it opens a console window.
60
+ detached: process.platform !== 'win32',
61
+ });
62
+ // The browser reads commands on fd 3 and writes replies on fd 4. A NUL byte ends each message.
63
+ const out = child.stdio[3], input = child.stdio[4];
64
+ out.on('error', () => { }); // a write after the exit fails; the exit handler below rejects the commands
65
+ let tail = '', dead = null, nextId = 1;
66
+ child.stderr.on('data', (d) => (tail = (tail + d.toString()).slice(-2048)));
67
+ const pending = new Map();
68
+ const events = [];
69
+ const stop = (e) => {
70
+ dead ??= e;
71
+ for (const w of [...pending.values(), ...events.splice(0)])
72
+ w.reject(dead);
73
+ pending.clear();
74
+ };
75
+ // The browser starts helper processes in its process group. A helper can outlive the browser and keep a pipe open.
76
+ const killGroup = () => {
77
+ try {
78
+ if (process.platform === 'win32')
79
+ child.kill('SIGKILL');
80
+ else if (child.pid)
81
+ process.kill(-child.pid, 'SIGKILL');
82
+ }
83
+ catch {
84
+ // the group is already gone
85
+ }
86
+ };
87
+ const closed = new Promise((done) => {
88
+ const end = (e) => (stop(e), done());
89
+ const stopped = (code) => new Error(`the browser stopped (exit ${code}): ${tail.trim()}`);
90
+ child.once('error', end);
91
+ child.once('exit', (code) => {
92
+ out.destroy(); // the write end of fd 3 does not close by itself, and 'close' waits for it
93
+ killGroup();
94
+ setTimeout(() => end(stopped(code)), 1000).unref(); // in case a pipe stays open after the group kill
95
+ });
96
+ child.once('close', (code) => end(stopped(code)));
97
+ });
98
+ let parts = [];
99
+ input.on('data', (chunk) => {
100
+ let start = 0;
101
+ for (let end = chunk.indexOf(0); end !== -1; start = end + 1, end = chunk.indexOf(0, start)) {
102
+ parts.push(chunk.subarray(start, end));
103
+ const msg = JSON.parse(Buffer.concat(parts).toString('utf8'));
104
+ parts = [];
105
+ if (msg.id !== undefined) {
106
+ const w = pending.get(msg.id);
107
+ pending.delete(msg.id);
108
+ if (msg.error)
109
+ w?.reject(new Error(`${w.method}: ${msg.error.message}`));
110
+ else
111
+ w?.resolve(msg.result);
112
+ }
113
+ else {
114
+ const i = events.findIndex((w) => w.event === msg.method && w.sessionId === msg.sessionId);
115
+ if (i !== -1)
116
+ events.splice(i, 1)[0].resolve(msg.params);
117
+ }
118
+ }
119
+ if (start < chunk.length)
120
+ parts.push(chunk.subarray(start));
121
+ });
122
+ /** A promise that rejects after the timeout; `add` registers the waiter, `drop` removes it. */
123
+ const timed = (label, add, drop, ms = timeoutMs) => new Promise((resolve, reject) => {
124
+ if (dead)
125
+ return reject(dead);
126
+ const timer = setTimeout(() => {
127
+ drop();
128
+ reject(new Error(`${label}: no reply in ${ms / 1000} s`));
129
+ }, ms);
130
+ add({ resolve: (v) => (clearTimeout(timer), resolve(v)), reject: (e) => (clearTimeout(timer), reject(e)) });
131
+ });
132
+ const cdp = {
133
+ send: (method, params = {}, sessionId, ms) => {
134
+ const id = nextId++;
135
+ return timed(method, (w) => {
136
+ pending.set(id, { ...w, method });
137
+ out.write(JSON.stringify({ id, method, params, sessionId }) + '\0');
138
+ }, () => pending.delete(id), ms);
139
+ },
140
+ once: (event, sessionId, ms) => {
141
+ let me;
142
+ return timed(event, (w) => events.push((me = { ...w, event, sessionId })), () => events.splice(events.indexOf(me), 1), ms);
143
+ },
144
+ };
145
+ // No Browser.close: the profile is a temp folder with nothing to keep, and Chrome takes about 10 s to exit after it.
146
+ const close = async () => {
147
+ killGroup();
148
+ await closed;
149
+ };
150
+ return cdp.send('Browser.getVersion').then(() => ({ cdp, close }), async (e) => {
151
+ await close();
152
+ throw e;
153
+ });
154
+ }
155
+ /** Set the page, pause the animations and take one PNG per frame time. */
156
+ export async function captureFrames(cdp, html, o) {
157
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
158
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
159
+ const send = (method, params) => cdp.send(method, params, sessionId);
160
+ const run = async (expression) => {
161
+ const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
162
+ if (r.exceptionDetails)
163
+ throw new Error(`Runtime.evaluate: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
164
+ return r.result.value;
165
+ };
166
+ await send('Emulation.setDeviceMetricsOverride', { width: o.width, height: o.height, deviceScaleFactor: o.scale, mobile: false });
167
+ // The figure picks its theme with a media query, so the run sets the query and leaves the SVG as it is.
168
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: o.dark ? 'dark' : 'light' }] });
169
+ // The page goes in as content, not as a file: a snap browser has a private /tmp and cannot read the temp folder.
170
+ const { frameTree } = await send('Page.getFrameTree');
171
+ await send('Page.setDocumentContent', { frameId: frameTree.frame.id, html });
172
+ // CSS animations are in getAnimations(). The packet moves with SMIL, which only the <svg> element controls.
173
+ const info = await run(`(async () => {
174
+ await document.fonts.ready;
175
+ const svg = document.querySelector('svg');
176
+ const css = document.getAnimations();
177
+ for (const a of css) a.pause();
178
+ svg.pauseAnimations();
179
+ let loop = 0;
180
+ for (const a of css) loop = Math.max(loop, Number(a.effect.getTiming().duration) || 0);
181
+ for (const m of svg.querySelectorAll('animate, animateMotion, animateTransform, set'))
182
+ try { loop = Math.max(loop, m.getSimpleDuration() * 1000); } catch {}
183
+ const r = svg.getBoundingClientRect();
184
+ return { loop, x: r.x, y: r.y, width: r.width, height: r.height };
185
+ })()`);
186
+ const n = Math.max(1, Math.round((info.loop * o.fps) / 1000));
187
+ const clip = { x: info.x, y: info.y, width: info.width, height: info.height, scale: 1 };
188
+ const pngs = [];
189
+ for (let i = 0; i < n; i++) {
190
+ const t = (i * 1000) / o.fps;
191
+ await run(`(() => {
192
+ for (const a of document.getAnimations()) a.currentTime = ${t};
193
+ document.querySelector('svg').setCurrentTime(${t / 1000});
194
+ // two frames: one frame can fire before the browser draws the new time under load
195
+ return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
196
+ })()`);
197
+ const { data } = await send('Page.captureScreenshot', { format: 'png', clip });
198
+ pngs.push(Buffer.from(data, 'base64'));
199
+ }
200
+ return { pngs, width: Math.round(info.width * o.scale), height: Math.round(info.height * o.scale), loopMs: info.loop };
201
+ }
package/dist/check.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The rules behind `flowfig check`. They never draw and never measure: a renderer gives a Scene, and these read it. So one rule set
2
2
  // covers the SVG (estimated text) and the React player (measured text), and any later view that gives a scene.
3
- import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, nodes, str, toBeat, } from './model.js';
3
+ import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
4
4
  import { textWidth } from './text.js';
5
5
  import { owners, parseSource } from './source.js';
6
6
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
@@ -34,9 +34,9 @@ export function checkSpec(fig) {
34
34
  for (const h of b.hops)
35
35
  if (!knownEdges.has(h.edge))
36
36
  out.push(err('unknown-id', [h.edge], `${where} moves along edge "${h.edge}", which does not exist`));
37
- for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? [])])
37
+ for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? []), ...(b.focus ?? [])])
38
38
  if (!known.has(id))
39
- out.push(err('unknown-id', [id], `${where}: show or light names box "${id}", which does not exist`));
39
+ out.push(err('unknown-id', [id], `${where}: show, light or focus names box "${id}", which does not exist`));
40
40
  }
41
41
  });
42
42
  const used = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => h.edge))));
@@ -48,6 +48,61 @@ export function checkSpec(fig) {
48
48
  for (const [who, source] of owners(fig))
49
49
  if (source != null && !parseSource(source))
50
50
  out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
51
+ const marks = nodes(fig.layout).map((n) => n.mark);
52
+ const starts = marks.filter((m) => m === 'start').length;
53
+ const ends = marks.filter((m) => m === 'end').length;
54
+ if (starts > 1 || (starts && !ends))
55
+ out.push(warn('mark-count', [], `a lifecycle has one start and at least one end: ${starts} start, ${ends} end`));
56
+ if (fig.timeline && fig.lanes)
57
+ out.push(warn('timeline-and-lanes', [], 'the figure sets timeline and lanes; the renderers draw the timeline and ignore lanes'));
58
+ if (fig.timeline) {
59
+ if (!isLanesLayout(fig.layout))
60
+ out.push(err('lanes-need-column', [], 'a timeline uses the lanes layout: a column group of labeled groups, one per track, with boxes only inside'));
61
+ for (const n of nodes(fig.layout)) {
62
+ if (n.from == null)
63
+ out.push(err('timeline-need-from', [n.id], `box "${n.id}" has no from date, and a timeline needs one`));
64
+ const [f, t] = [n.from == null ? null : dayOf(n.from), n.to == null ? null : dayOf(n.to)];
65
+ for (const [name, v, d] of [
66
+ ['from', n.from, f],
67
+ ['to', n.to, t],
68
+ ])
69
+ if (v != null && d == null)
70
+ out.push(err('bad-date', [n.id], `box "${n.id}": ${name} "${v}" is not a real YYYY-MM-DD date`));
71
+ if (f != null && t != null && t < f)
72
+ out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
73
+ }
74
+ // A dependent item starts after its source ends. A milestone ends the day it starts.
75
+ const span = new Map(nodes(fig.layout).map((n) => [n.id, [n.from && dayOf(n.from), (n.to && dayOf(n.to)) ?? (n.from && dayOf(n.from))]]));
76
+ for (const e of fig.edges) {
77
+ const start = span.get(e.to)?.[0];
78
+ const end = span.get(e.from)?.[1];
79
+ if (start != null && end != null && start <= end)
80
+ out.push(warn('timeline-dependency-order', [e.from, e.to], `"${e.to}" does not start after "${e.from}" ends`));
81
+ }
82
+ if (fig.today != null && dayOf(fig.today) == null)
83
+ out.push(err('bad-date', [], `today "${fig.today}" is not a real YYYY-MM-DD date`));
84
+ }
85
+ else if (fig.lanes) {
86
+ const top = fig.layout;
87
+ if (!isLanesLayout(top))
88
+ out.push(err('lanes-need-column', [], 'lanes need a column group of labeled groups, one per lane, with boxes only inside'));
89
+ else {
90
+ const cols = laneColumns(fig);
91
+ for (const lane of top.children) {
92
+ const at = new Map();
93
+ for (const n of lane.children) {
94
+ const c = cols.get(n.id);
95
+ const other = at.get(c);
96
+ if (other != null)
97
+ out.push(warn('lane-column-taken', [other, n.id], `boxes "${other}" and "${n.id}" share column ${c} in lane "${str(lane.label)}"`));
98
+ at.set(c, n.id);
99
+ }
100
+ }
101
+ }
102
+ }
103
+ for (const n of nodes(fig.layout))
104
+ if (n.at != null && !validAt(n.at))
105
+ out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
51
106
  return out;
52
107
  }
53
108
  /** A point on the cubic Bézier at `t`. */
@@ -70,6 +125,8 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
70
125
  out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
71
126
  }
72
127
  for (const e of scene.edges) {
128
+ if (e.behind)
129
+ continue;
73
130
  const pts = Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
74
131
  for (const b of scene.boxes)
75
132
  if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))