@neta-art/cohub 8.21.1 → 8.22.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.
@@ -1,22 +1,27 @@
1
1
  import { BOARD_FONT_STACK } from "../../protocol/dist/board-constants.js";
2
- import { connectionArrowheads, resolveConnection } from "../core/connections.js";
2
+ import { rectsIntersect } from "../geometry.js";
3
+ import { connectionArrowheads, createConnectionGeometryCache } from "../core/connections.js";
3
4
  import { pickBoardColor } from "../core/palette.js";
4
- import { syncTextResolution, textResolutionForZoom } from "./text-resolution.js";
5
+ import { syncTextResolution, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
5
6
  import { Container, Graphics, Text } from "pixi.js";
6
7
  //#region src/board/render/connection-layer.ts
7
8
  /**
8
9
  * Connection drawing.
9
10
  *
10
- * Connections render into one shared layer beneath the cards rather than as a
11
+ * Connections render into shared layers beneath the cards rather than as a
11
12
  * container per relation. A connection is a thin stroke with no texture and no
12
13
  * interactive chrome of its own, so a per-connection container would add a
13
14
  * transform, a render group and a draw call each for geometry that batches
14
15
  * perfectly — on a densely connected board that is the difference between a few
15
16
  * draw calls and a few thousand.
16
17
  *
17
- * Labels are the exception: text needs its own object to rasterise, so a `Text`
18
- * is materialised only for connections that actually carry one and is pooled by
19
- * connection id.
18
+ * Tessellation is the real cost, so both layers are cached: `base` holds idle
19
+ * relations, `live` those touching manipulated, selected or hovered nodes.
20
+ * Details too small to read degrade to plain strokes; the document never changes.
21
+ *
22
+ * Labels are the exception to batching: text needs its own object to rasterise,
23
+ * so a `Text` is materialised only for visible connections that carry one and is
24
+ * pooled by connection id.
20
25
  */
21
26
  /** Arrowhead length relative to stroke width, and its floor in world units. */
22
27
  const HEAD_SCALE = 5.5;
@@ -25,85 +30,203 @@ const HEAD_SPREAD = Math.PI / 6;
25
30
  /** Dash pattern for `dashed` connections, in world units. */
26
31
  const DASH_LENGTH = 10;
27
32
  const DASH_GAP = 7;
28
- /** Draw an open chevron arrowhead aimed along `angle`. */
29
- function drawArrowhead(graphics, tip, angle, size, color, width, alpha) {
30
- graphics.moveTo(tip.x - size * Math.cos(angle - HEAD_SPREAD), tip.y - size * Math.sin(angle - HEAD_SPREAD)).lineTo(tip.x, tip.y).lineTo(tip.x - size * Math.cos(angle + HEAD_SPREAD), tip.y - size * Math.sin(angle + HEAD_SPREAD)).stroke({
31
- color,
32
- width,
33
- alpha,
34
- cap: "round",
35
- join: "round"
36
- });
33
+ const LABEL_FONT_SIZE = 12;
34
+ /** Minimum on-screen sizes before a detail is drawn. */
35
+ const MIN_DASH_PX = 4;
36
+ const MIN_HEAD_PX = 3;
37
+ const MIN_LABEL_PX = 5;
38
+ const MIN_SEGMENT_PX = 2;
39
+ const MIN_ROUND_PX = 2;
40
+ /** Dashes per frame; past it all dashed relations draw solid (with hysteresis). */
41
+ const DASH_BUDGET = 4e4;
42
+ const DASH_RESUME_RATIO = .8;
43
+ function detailForZoom(zoom) {
44
+ const bucket = 2 ** (Math.floor(Math.log2(Math.max(zoom, 1e-4)) * 2) / 2);
45
+ return {
46
+ zoom: bucket,
47
+ dashes: DASH_LENGTH * bucket >= MIN_DASH_PX,
48
+ labels: LABEL_FONT_SIZE * bucket >= MIN_LABEL_PX,
49
+ minWidth: 1 / bucket,
50
+ minStep: MIN_SEGMENT_PX / bucket
51
+ };
37
52
  }
38
- /** Trace a polyline as a dashed path, walking segment by segment. */
39
- function traceDashed(graphics, path) {
53
+ /** Visit each dash piece inside `clip`; `start` marks the first piece of a dash. */
54
+ function walkDashes(path, clip, visit) {
40
55
  let penDown = true;
41
56
  let remaining = DASH_LENGTH;
42
- let current = path[0];
43
- if (!current) return;
44
- graphics.moveTo(current.x, current.y);
57
+ let open = false;
45
58
  for (let index = 1; index < path.length; index += 1) {
46
- const next = path[index];
47
- if (!next) continue;
48
- let segmentRemaining = Math.hypot(next.x - current.x, next.y - current.y);
49
- let from = current;
50
- while (segmentRemaining > 1e-4) {
51
- const step = Math.min(segmentRemaining, remaining);
52
- const ratio = step / segmentRemaining;
53
- const to = {
54
- x: from.x + (next.x - from.x) * ratio,
55
- y: from.y + (next.y - from.y) * ratio
56
- };
57
- if (penDown) graphics.lineTo(to.x, to.y);
58
- else graphics.moveTo(to.x, to.y);
59
+ const a = path[index - 1];
60
+ const b = path[index];
61
+ if (!a || !b) continue;
62
+ const dx = b.x - a.x;
63
+ const dy = b.y - a.y;
64
+ const length = Math.hypot(dx, dy);
65
+ let travelled = 0;
66
+ while (length - travelled > 1e-4) {
67
+ const step = Math.min(length - travelled, remaining);
68
+ if (penDown) {
69
+ const t0 = travelled / length;
70
+ const t1 = (travelled + step) / length;
71
+ const x0 = a.x + dx * t0;
72
+ const y0 = a.y + dy * t0;
73
+ const x1 = a.x + dx * t1;
74
+ const y1 = a.y + dy * t1;
75
+ if (!clip || Math.max(x0, x1) >= clip.x && Math.min(x0, x1) <= clip.x + clip.width && Math.max(y0, y1) >= clip.y && Math.min(y0, y1) <= clip.y + clip.height) {
76
+ visit(x0, y0, x1, y1, !open);
77
+ open = true;
78
+ } else open = false;
79
+ }
80
+ travelled += step;
59
81
  remaining -= step;
60
- segmentRemaining -= step;
61
- from = to;
62
82
  if (remaining <= 1e-4) {
63
83
  penDown = !penDown;
64
84
  remaining = penDown ? DASH_LENGTH : DASH_GAP;
85
+ open = false;
65
86
  }
66
87
  }
67
- current = next;
68
88
  }
69
89
  }
90
+ function countDashes(entries, clip) {
91
+ let count = 0;
92
+ for (const { connection, resolved } of entries) {
93
+ if (connection.style.line !== "dashed") continue;
94
+ walkDashes(resolved.path, clip, (_x0, _y0, _x1, _y1, start) => {
95
+ if (start) count += 1;
96
+ });
97
+ if (count > DASH_BUDGET) break;
98
+ }
99
+ return count;
100
+ }
101
+ function traceSolid(graphics, path, minStep) {
102
+ const first = path[0];
103
+ const last = path[path.length - 1];
104
+ if (!first || !last) return;
105
+ graphics.moveTo(first.x, first.y);
106
+ let previous = first;
107
+ for (let index = 1; index < path.length - 1; index += 1) {
108
+ const point = path[index];
109
+ if (!point) continue;
110
+ if (Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y) < minStep) continue;
111
+ graphics.lineTo(point.x, point.y);
112
+ previous = point;
113
+ }
114
+ graphics.lineTo(last.x, last.y);
115
+ }
116
+ /** Trace an open chevron arrowhead aimed along `angle`. */
117
+ function traceArrowhead(graphics, tip, angle, size) {
118
+ graphics.moveTo(tip.x - size * Math.cos(angle - HEAD_SPREAD), tip.y - size * Math.sin(angle - HEAD_SPREAD)).lineTo(tip.x, tip.y).lineTo(tip.x - size * Math.cos(angle + HEAD_SPREAD), tip.y - size * Math.sin(angle + HEAD_SPREAD));
119
+ }
120
+ /** Stroke each style once instead of once per relation. */
121
+ function createStrokeBatch(graphics) {
122
+ const groups = /* @__PURE__ */ new Map();
123
+ return {
124
+ add(style, trace) {
125
+ const key = `${style.color}|${style.width}|${style.alpha}|${style.cap}|${style.join}`;
126
+ const group = groups.get(key);
127
+ if (group) group.traces.push(trace);
128
+ else groups.set(key, {
129
+ style,
130
+ traces: [trace]
131
+ });
132
+ },
133
+ flush() {
134
+ for (const { style, traces } of groups.values()) {
135
+ for (const trace of traces) trace();
136
+ graphics.stroke(style);
137
+ }
138
+ groups.clear();
139
+ }
140
+ };
141
+ }
142
+ function drawConnections(graphics, entries, states, input, detail, clip, dashes) {
143
+ const batch = createStrokeBatch(graphics);
144
+ entries.forEach(({ connection, resolved }, index) => {
145
+ const state = states[index] ?? "idle";
146
+ const color = pickBoardColor(input.colors, connection.style.color, input.colorScheme);
147
+ const width = Math.max(connection.style.size + (state === "selected" ? 1 : 0), detail.minWidth);
148
+ const round = width * detail.zoom >= MIN_ROUND_PX;
149
+ const alpha = state === "idle" ? .9 : 1;
150
+ const path = resolved.path;
151
+ const dashed = dashes && connection.style.line === "dashed";
152
+ batch.add({
153
+ color: color.stroke,
154
+ width,
155
+ alpha,
156
+ cap: dashed || !round ? "butt" : "round",
157
+ join: round ? "round" : "miter"
158
+ }, dashed ? () => walkDashes(path, clip, (x0, y0, x1, y1, start) => {
159
+ if (start) graphics.moveTo(x0, y0);
160
+ graphics.lineTo(x1, y1);
161
+ }) : () => traceSolid(graphics, path, detail.minStep));
162
+ const headSize = Math.max(HEAD_MIN, connection.style.size * HEAD_SCALE);
163
+ if (headSize * detail.zoom < MIN_HEAD_PX) return;
164
+ const heads = connectionArrowheads(connection);
165
+ const headStyle = {
166
+ color: color.stroke,
167
+ width,
168
+ alpha,
169
+ cap: round ? "round" : "butt",
170
+ join: round ? "round" : "miter"
171
+ };
172
+ const tip = path[path.length - 1];
173
+ const beforeTip = path[path.length - 2];
174
+ if (heads.atTarget && tip && beforeTip) {
175
+ const angle = Math.atan2(tip.y - beforeTip.y, tip.x - beforeTip.x);
176
+ batch.add(headStyle, () => traceArrowhead(graphics, tip, angle, headSize));
177
+ }
178
+ const origin = path[0];
179
+ const afterOrigin = path[1];
180
+ if (heads.atSource && origin && afterOrigin) {
181
+ const angle = Math.atan2(origin.y - afterOrigin.y, origin.x - afterOrigin.x);
182
+ batch.add(headStyle, () => traceArrowhead(graphics, origin, angle, headSize));
183
+ }
184
+ });
185
+ batch.flush();
186
+ }
187
+ function isCurrent(drawn, entries, states, view, colors) {
188
+ return drawn !== null && drawn.view === view && drawn.colors === colors && sameItems(drawn.entries, entries) && sameItems(drawn.states, states);
189
+ }
190
+ function sameItems(a, b) {
191
+ if (a.length !== b.length) return false;
192
+ for (let index = 0; index < a.length; index += 1) if (a[index] !== b[index]) return false;
193
+ return true;
194
+ }
70
195
  function createConnectionLayer(options) {
71
- let graphics = null;
72
- let labelLayer = null;
196
+ let layers = null;
197
+ const geometry = createConnectionGeometryCache();
73
198
  const labels = /* @__PURE__ */ new Map();
74
- let resolvedById = /* @__PURE__ */ new Map();
199
+ let lastConnections = null;
200
+ let drawnBase = null;
201
+ let drawnLive = null;
202
+ let withinBudget = true;
75
203
  function ensureAttached() {
76
- if (graphics && labelLayer) return {
77
- graphics,
78
- labelLayer
204
+ if (layers) return layers;
205
+ const next = {
206
+ base: new Graphics({ label: "board-connections" }),
207
+ live: new Graphics({ label: "board-connections-live" }),
208
+ labels: new Container({ label: "board-connection-labels" })
79
209
  };
80
- const nextGraphics = new Graphics({ label: "board-connections" });
81
- const nextLabels = new Container({ label: "board-connection-labels" });
82
210
  if (options.zIndex !== void 0) {
83
- nextGraphics.zIndex = options.zIndex;
84
- nextLabels.zIndex = options.zIndex;
211
+ next.base.zIndex = options.zIndex;
212
+ next.live.zIndex = options.zIndex;
213
+ next.labels.zIndex = options.zIndex;
85
214
  }
86
- options.parent.addChild(nextGraphics, nextLabels);
87
- graphics = nextGraphics;
88
- labelLayer = nextLabels;
89
- return {
90
- graphics: nextGraphics,
91
- labelLayer: nextLabels
92
- };
215
+ options.parent.addChild(next.base, next.live, next.labels);
216
+ layers = next;
217
+ return next;
93
218
  }
94
219
  function releaseLabel(connectionId) {
95
220
  const entry = labels.get(connectionId);
96
221
  if (!entry) return;
97
- labelLayer?.removeChild(entry.text);
222
+ layers?.labels.removeChild(entry.text);
98
223
  entry.text.destroy();
99
224
  labels.delete(connectionId);
100
225
  }
101
- function syncLabel(connection, resolved, input, color, host) {
226
+ function syncLabel({ connection, resolved }, input, host) {
102
227
  const value = connection.label.trim();
103
- if (!value) {
104
- releaseLabel(connection.id);
105
- return;
106
- }
228
+ if (!value) return false;
229
+ const color = pickBoardColor(input.colors, connection.style.color, input.colorScheme).label;
107
230
  let entry = labels.get(connection.id);
108
231
  if (!entry) {
109
232
  const resolution = textResolutionForZoom(input.zoom);
@@ -112,7 +235,7 @@ function createConnectionLayer(options) {
112
235
  style: {
113
236
  fill: color,
114
237
  fontFamily: BOARD_FONT_STACK,
115
- fontSize: 12,
238
+ fontSize: LABEL_FONT_SIZE,
116
239
  fontWeight: "500"
117
240
  },
118
241
  resolution,
@@ -135,82 +258,111 @@ function createConnectionLayer(options) {
135
258
  entry.text.style.fill = color;
136
259
  }
137
260
  entry.text.position.set(resolved.mid.x, resolved.mid.y);
261
+ return true;
138
262
  }
139
- function sync(input) {
140
- if (input.connections.length === 0 && !graphics) {
141
- resolvedById = /* @__PURE__ */ new Map();
142
- return;
263
+ function drawLayer(graphics, layer, input, detail, clip) {
264
+ graphics.clear();
265
+ drawConnections(graphics, layer.entries, layer.states, input, detail, clip, layer.dashes);
266
+ const labelIds = /* @__PURE__ */ new Set();
267
+ if (detail.labels && layers) {
268
+ for (const entry of layer.entries) if (syncLabel(entry, input, layers.labels)) labelIds.add(entry.connection.id);
143
269
  }
270
+ return {
271
+ ...layer,
272
+ labelIds
273
+ };
274
+ }
275
+ function sync(input) {
276
+ if (input.connections.length === 0 && !layers) return;
144
277
  const host = ensureAttached();
145
- host.graphics.clear();
146
- const next = /* @__PURE__ */ new Map();
147
- const selected = input.selectedIds ?? /* @__PURE__ */ new Set();
148
- const skip = input.skipIds;
149
- const minWidth = 1 / Math.max(input.zoom, 1e-4);
278
+ if (input.connections !== lastConnections) {
279
+ geometry.retain(input.connections);
280
+ lastConnections = input.connections;
281
+ }
282
+ const detail = detailForZoom(input.zoom);
283
+ const cull = input.cullRect ?? null;
284
+ const clip = detail.dashes ? cull : null;
285
+ const selected = input.selectedIds;
286
+ const liveNodes = input.liveNodeIds;
287
+ const baseEntries = [];
288
+ const baseStates = [];
289
+ const liveEntries = [];
290
+ const liveStates = [];
291
+ let hovered = null;
150
292
  for (const connection of input.connections) {
151
- const resolved = resolveConnection(connection, input.getFrame);
152
- if (!resolved) continue;
153
- next.set(connection.id, resolved);
154
- if (skip?.has(connection.id)) {
155
- releaseLabel(connection.id);
156
- continue;
157
- }
158
- const isSelected = selected.has(connection.id);
159
- const isHovered = input.hoveredId === connection.id;
160
- const color = pickBoardColor(input.colors, connection.style.color, input.colorScheme);
161
- const width = Math.max(connection.style.size + (isSelected ? 1 : 0), minWidth);
162
- const alpha = isSelected || isHovered ? 1 : .9;
163
- if (connection.style.line === "dashed") traceDashed(host.graphics, resolved.path);
164
- else {
165
- const first = resolved.path[0];
166
- if (!first) continue;
167
- host.graphics.moveTo(first.x, first.y);
168
- for (let index = 1; index < resolved.path.length; index += 1) {
169
- const point = resolved.path[index];
170
- if (point) host.graphics.lineTo(point.x, point.y);
171
- }
172
- }
173
- host.graphics.stroke({
174
- color: color.stroke,
175
- width,
176
- alpha,
177
- cap: "round",
178
- join: "round"
179
- });
180
- const heads = connectionArrowheads(connection);
181
- const headSize = Math.max(HEAD_MIN, connection.style.size * HEAD_SCALE);
182
- if (heads.atTarget) {
183
- const tip = resolved.path[resolved.path.length - 1];
184
- const previous = resolved.path[resolved.path.length - 2];
185
- if (tip && previous) drawArrowhead(host.graphics, tip, Math.atan2(tip.y - previous.y, tip.x - previous.x), headSize, color.stroke, width, alpha);
293
+ const entry = geometry.get(connection, input.getFrame);
294
+ if (!entry || input.skipIds?.has(connection.id)) continue;
295
+ if (cull && !rectsIntersect(entry.bounds, cull)) continue;
296
+ if (selected?.has(connection.id)) {
297
+ liveEntries.push(entry);
298
+ liveStates.push("selected");
299
+ } else if (liveNodes?.has(connection.source.itemId) || liveNodes?.has(connection.target.itemId)) {
300
+ liveEntries.push(entry);
301
+ liveStates.push(connection.id === input.hoveredId ? "hovered" : "idle");
302
+ } else {
303
+ baseEntries.push(entry);
304
+ baseStates.push("idle");
305
+ if (connection.id === input.hoveredId) hovered = entry;
186
306
  }
187
- if (heads.atSource) {
188
- const tip = resolved.path[0];
189
- const next2 = resolved.path[1];
190
- if (tip && next2) drawArrowhead(host.graphics, tip, Math.atan2(tip.y - next2.y, tip.x - next2.x), headSize, color.stroke, width, alpha);
191
- }
192
- syncLabel(connection, resolved, input, color.label, host.labelLayer);
193
307
  }
194
- for (const connectionId of [...labels.keys()]) if (!next.has(connectionId)) releaseLabel(connectionId);
195
- resolvedById = next;
308
+ if (hovered) {
309
+ liveEntries.push(hovered);
310
+ liveStates.push("hovered");
311
+ }
312
+ const view = `${detail.zoom}|${textZoomBucket(input.zoom)}|${input.colorScheme}|${clip ? `${clip.x},${clip.y},${clip.width},${clip.height}` : ""}`;
313
+ const baseCurrent = isCurrent(drawnBase, baseEntries, baseStates, view, input.colors);
314
+ const liveCurrent = isCurrent(drawnLive, liveEntries, liveStates, view, input.colors);
315
+ const countFor = (current, drawn, entries) => !detail.dashes ? 0 : current && drawn ? drawn.dashCount : countDashes(entries, clip);
316
+ const baseDashes = countFor(baseCurrent, drawnBase, baseEntries);
317
+ const liveDashes = countFor(liveCurrent, drawnLive, hovered ? liveEntries.slice(0, -1) : liveEntries);
318
+ const total = baseDashes + liveDashes;
319
+ withinBudget = withinBudget ? total <= DASH_BUDGET : total <= DASH_BUDGET * DASH_RESUME_RATIO;
320
+ const dashes = detail.dashes && withinBudget;
321
+ const redraw = (graphics, entries, states, dashCount) => drawLayer(graphics, {
322
+ entries,
323
+ states,
324
+ view,
325
+ colors: input.colors,
326
+ dashCount,
327
+ dashes
328
+ }, input, detail, clip);
329
+ let labelsChanged = false;
330
+ if (!baseCurrent || drawnBase?.dashes !== dashes) {
331
+ drawnBase = redraw(host.base, baseEntries, baseStates, baseDashes);
332
+ labelsChanged = true;
333
+ }
334
+ if (!liveCurrent || drawnLive?.dashes !== dashes) {
335
+ drawnLive = redraw(host.live, liveEntries, liveStates, liveDashes);
336
+ labelsChanged = true;
337
+ }
338
+ if (!labelsChanged) return;
339
+ const wanted = /* @__PURE__ */ new Set([...drawnBase?.labelIds ?? [], ...drawnLive?.labelIds ?? []]);
340
+ for (const connectionId of [...labels.keys()]) if (!wanted.has(connectionId)) releaseLabel(connectionId);
196
341
  }
197
342
  return {
198
343
  sync,
199
- resolved: (connectionId) => resolvedById.get(connectionId) ?? null,
344
+ resolved: (connectionId) => geometry.peek(connectionId)?.resolved ?? null,
200
345
  get children() {
201
- const list = [];
202
- if (graphics) list.push(graphics);
203
- if (labelLayer) list.push(labelLayer);
204
- return list;
346
+ return layers ? [
347
+ layers.base,
348
+ layers.live,
349
+ layers.labels
350
+ ] : [];
205
351
  },
206
352
  destroy: () => {
207
353
  for (const entry of labels.values()) entry.text.destroy();
208
354
  labels.clear();
209
- graphics?.destroy();
210
- labelLayer?.destroy({ children: true });
211
- graphics = null;
212
- labelLayer = null;
213
- resolvedById = /* @__PURE__ */ new Map();
355
+ if (layers) {
356
+ layers.base.destroy();
357
+ layers.live.destroy();
358
+ layers.labels.destroy({ children: true });
359
+ }
360
+ layers = null;
361
+ drawnBase = null;
362
+ drawnLive = null;
363
+ withinBudget = true;
364
+ lastConnections = null;
365
+ geometry.clear();
214
366
  }
215
367
  };
216
368
  }