@bruno00o/graph 0.2.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.
@@ -449,6 +449,39 @@ function createGraphModel(options = {}) {
449
449
  }
450
450
  };
451
451
  }
452
+ // src/core/lasso.ts
453
+ function insidePolygon(x, y, polygon) {
454
+ let inside = false;
455
+ let previous = polygon[polygon.length - 1];
456
+ for (const point of polygon) {
457
+ if (previous && point.y > y !== previous.y > y && x < (previous.x - point.x) * (y - point.y) / (previous.y - point.y) + point.x) inside = !inside;
458
+ previous = point;
459
+ }
460
+ return inside;
461
+ }
462
+ function nodesInside(polygon, nodes, positions) {
463
+ if (polygon.length < 3) return [];
464
+ let left = Number.POSITIVE_INFINITY;
465
+ let right = Number.NEGATIVE_INFINITY;
466
+ let top = Number.POSITIVE_INFINITY;
467
+ let bottom = Number.NEGATIVE_INFINITY;
468
+ for (const point of polygon) {
469
+ left = Math.min(left, point.x);
470
+ right = Math.max(right, point.x);
471
+ top = Math.min(top, point.y);
472
+ bottom = Math.max(bottom, point.y);
473
+ }
474
+ const inside = [];
475
+ for (let index = 0; index < nodes.capacity; index++) {
476
+ if (nodes.alive[index] !== 1) continue;
477
+ const x = positions.x(index);
478
+ const y = positions.y(index);
479
+ if (x < left || x > right || y < top || y > bottom) continue;
480
+ const key = nodes.keys[index];
481
+ if (key !== void 0 && insidePolygon(x, y, polygon)) inside.push(key);
482
+ }
483
+ return inside;
484
+ }
452
485
  // src/core/edge-curve.ts
453
486
  const loopTurn = .6;
454
487
  const loopGrowth = .5;
@@ -802,10 +835,13 @@ function compileNodeMark(mark) {
802
835
  y: channel(options.y),
803
836
  label: channel(typeof label === "object" ? label.text : label),
804
837
  secondary: channel(typeof label === "object" ? label.secondary : void 0),
838
+ labelBackground: style(typeof label === "object" ? label.background : void 0),
805
839
  shape: style(options.shape),
806
840
  fill: style(options.fill),
807
841
  stroke: style(options.stroke),
808
842
  strokeWidth: style(options.strokeWidth),
843
+ outline: style(options.outline),
844
+ outlineWidth: style(options.outlineWidth),
809
845
  opacity: style(options.opacity),
810
846
  image: style(options.image),
811
847
  badges,
@@ -968,6 +1004,7 @@ const lightTheme = {
968
1004
  "focus-ring": "#c93700",
969
1005
  halo: "#c9370047",
970
1006
  "label-halo": "#f7f7f4",
1007
+ "label-background": "#ffffff",
971
1008
  "node-ring": "#f7f7f4",
972
1009
  "badge-fill": "#191d22",
973
1010
  "badge-ink": "#f7f7f4",
@@ -992,9 +1029,15 @@ const defaults = {
992
1029
  nodeFill: themeColor("node"),
993
1030
  nodeStroke: themeColor("node-ring"),
994
1031
  nodeStrokeWidth: 1,
1032
+ nodeOutline: themeColor("node-ring"),
1033
+ nodeOutlineWidth: 2,
1034
+ labelBackground: themeColor("label-background"),
995
1035
  edgeWidth: 1.5,
996
1036
  edgeWidthRange: [1, 6],
997
- edgeColor: themeColor("edge")
1037
+ edgeColor: themeColor("edge"),
1038
+ badgeFill: themeColor("badge-fill"),
1039
+ badgeInk: themeColor("badge-ink"),
1040
+ badgeRing: themeColor("node-ring")
998
1041
  };
999
1042
  const builtInStyles = {
1000
1043
  nodes: {
@@ -1148,6 +1191,13 @@ function resolveNumberScale(options, fallback) {
1148
1191
  };
1149
1192
  }
1150
1193
  // src/core/columns.ts
1194
+ const noColor = 4294967295;
1195
+ const cornerCodes = {
1196
+ topLeft: 0,
1197
+ topRight: 1,
1198
+ bottomLeft: 2,
1199
+ bottomRight: 3
1200
+ };
1151
1201
  const shapeCodes = {
1152
1202
  circle: 0,
1153
1203
  square: 1,
@@ -1169,6 +1219,11 @@ function grow$1(array, length) {
1169
1219
  next.set(array);
1170
1220
  return next;
1171
1221
  }
1222
+ function growColor(array, length) {
1223
+ const next = new Uint32Array(length).fill(noColor);
1224
+ next.set(array);
1225
+ return next;
1226
+ }
1172
1227
  function nextCapacity(capacity, index) {
1173
1228
  return Math.max(64, capacity * 2, index + 1);
1174
1229
  }
@@ -1182,11 +1237,16 @@ var NodeColumns = class {
1182
1237
  fill = /* @__PURE__ */ new Uint32Array(0);
1183
1238
  stroke = /* @__PURE__ */ new Uint32Array(0);
1184
1239
  strokeWidth = /* @__PURE__ */ new Float32Array(0);
1240
+ outline = /* @__PURE__ */ new Uint32Array(0);
1241
+ outlineWidth = /* @__PURE__ */ new Float32Array(0);
1185
1242
  opacity = /* @__PURE__ */ new Float32Array(0);
1186
1243
  screenUnits = /* @__PURE__ */ new Uint8Array(0);
1187
1244
  label = [];
1188
1245
  secondary = [];
1246
+ labelBackground = /* @__PURE__ */ new Uint32Array(0);
1189
1247
  image = [];
1248
+ imageScale = /* @__PURE__ */ new Float32Array(0);
1249
+ imageColor = /* @__PURE__ */ new Uint32Array(0);
1190
1250
  badges = [];
1191
1251
  reserve(index) {
1192
1252
  if (index < this.capacity) return;
@@ -1198,8 +1258,13 @@ var NodeColumns = class {
1198
1258
  this.fill = grow$1(this.fill, capacity);
1199
1259
  this.stroke = grow$1(this.stroke, capacity);
1200
1260
  this.strokeWidth = grow$1(this.strokeWidth, capacity);
1261
+ this.outline = grow$1(this.outline, capacity);
1262
+ this.outlineWidth = grow$1(this.outlineWidth, capacity);
1201
1263
  this.opacity = grow$1(this.opacity, capacity);
1202
1264
  this.screenUnits = grow$1(this.screenUnits, capacity);
1265
+ this.labelBackground = growColor(this.labelBackground, capacity);
1266
+ this.imageScale = grow$1(this.imageScale, capacity);
1267
+ this.imageColor = growColor(this.imageColor, capacity);
1203
1268
  this.capacity = capacity;
1204
1269
  }
1205
1270
  clear(index) {
@@ -1349,8 +1414,8 @@ function createEdgeLanes() {
1349
1414
  }
1350
1415
  function link(index, source, target) {
1351
1416
  reserve(index);
1352
- const low = source < target ? source : target;
1353
- const high = source < target ? target : source;
1417
+ const low = Math.min(source, target);
1418
+ const high = Math.max(source, target);
1354
1419
  const current = slots[index] ?? -1;
1355
1420
  if (current >= 0) {
1356
1421
  if (lows[current] === low && highs[current] === high) return;
@@ -1468,18 +1533,42 @@ function finiteOr(value, fallback) {
1468
1533
  function codeOf(codes, value) {
1469
1534
  return typeof value === "string" && Object.hasOwn(codes, value) ? codes[value] : 0;
1470
1535
  }
1471
- function readBadges(mark, datum) {
1536
+ function readBadges(mark, datum, palette) {
1472
1537
  if (mark.badges.length === 0) return null;
1473
- const badges = {};
1474
- let count = 0;
1538
+ const badges = [];
1475
1539
  for (const [corner, read] of mark.badges) {
1476
1540
  const badge = read(datum);
1477
- if (typeof badge === "object" && badge !== null) {
1478
- badges[corner] = badge;
1479
- count++;
1480
- }
1541
+ if (typeof badge !== "object" || badge === null) continue;
1542
+ const { text, fill, color } = badge;
1543
+ badges.push({
1544
+ corner: cornerCodes[corner],
1545
+ text: typeof text === "number" ? String(text) : toText(text) || null,
1546
+ fill: palette.id(toColor(fill) ?? defaults.badgeFill),
1547
+ color: palette.id(toColor(color) ?? defaults.badgeInk),
1548
+ ring: palette.id(defaults.badgeRing)
1549
+ });
1481
1550
  }
1482
- return count === 0 ? null : badges;
1551
+ return badges.length === 0 ? null : badges;
1552
+ }
1553
+ function readBackground(value, palette) {
1554
+ if (value === true) return palette.id(defaults.labelBackground);
1555
+ const color = toColor(value);
1556
+ return color ? palette.id(color) : noColor;
1557
+ }
1558
+ function readImage(value) {
1559
+ if (typeof value === "string") return value === "" ? null : {
1560
+ src: value,
1561
+ scale: 1,
1562
+ color: null
1563
+ };
1564
+ if (typeof value !== "object" || value === null) return null;
1565
+ const { src, scale, color } = value;
1566
+ if (typeof src !== "string" || src === "") return null;
1567
+ return {
1568
+ src,
1569
+ scale: Math.max(0, finiteOr(scale, 1)),
1570
+ color: toColor(color)
1571
+ };
1483
1572
  }
1484
1573
  function layersOf(marks) {
1485
1574
  const slots = {
@@ -1565,12 +1654,19 @@ function createScene() {
1565
1654
  nodes.fill[index] = palette.id(fill);
1566
1655
  nodes.stroke[index] = palette.id(toColor(mark.stroke?.(datum)) ?? defaults.nodeStroke);
1567
1656
  nodes.strokeWidth[index] = finiteOr(mark.strokeWidth?.(datum), defaults.nodeStrokeWidth);
1657
+ const outline = mark.outline ? toColor(mark.outline(datum)) : null;
1658
+ nodes.outline[index] = palette.id(outline ?? defaults.nodeOutline);
1659
+ nodes.outlineWidth[index] = mark.outline && !outline ? 0 : Math.max(0, finiteOr(mark.outlineWidth?.(datum), outline ? defaults.nodeOutlineWidth : 0));
1568
1660
  nodes.opacity[index] = Math.min(1, Math.max(0, finiteOr(mark.opacity?.(datum), 1)));
1569
1661
  nodes.screenUnits[index] = mark.screenUnits ? 1 : 0;
1570
1662
  nodes.label[index] = toText(mark.label?.(datum));
1571
1663
  nodes.secondary[index] = toText(mark.secondary?.(datum));
1572
- nodes.image[index] = toText(mark.image?.(datum));
1573
- nodes.badges[index] = readBadges(mark, datum);
1664
+ nodes.labelBackground[index] = readBackground(mark.labelBackground?.(datum), palette);
1665
+ const image = readImage(mark.image?.(datum));
1666
+ nodes.image[index] = image?.src ?? null;
1667
+ nodes.imageScale[index] = image?.scale ?? 1;
1668
+ nodes.imageColor[index] = image?.color ? palette.id(image.color) : noColor;
1669
+ nodes.badges[index] = readBadges(mark, datum, palette);
1574
1670
  dirtyNodes.add(index);
1575
1671
  }
1576
1672
  function writeEdge(model, record) {
@@ -1710,6 +1806,47 @@ function createScene() {
1710
1806
  }
1711
1807
  };
1712
1808
  }
1809
+ // src/core/spatial.ts
1810
+ const sideWeight = 2;
1811
+ const cone = 2;
1812
+ function nearestNode(point, nodes, positions) {
1813
+ let best = -1;
1814
+ let bestDistance = Number.POSITIVE_INFINITY;
1815
+ for (let index = 0; index < nodes.capacity; index++) {
1816
+ if (nodes.alive[index] !== 1) continue;
1817
+ const distance = Math.hypot(positions.x(index) - point.x, positions.y(index) - point.y);
1818
+ if (distance < bestDistance) {
1819
+ best = index;
1820
+ bestDistance = distance;
1821
+ }
1822
+ }
1823
+ return best;
1824
+ }
1825
+ function nodeToward(from, direction, nodes, positions) {
1826
+ const x = positions.x(from);
1827
+ const y = positions.y(from);
1828
+ const length = Math.hypot(direction.x, direction.y);
1829
+ if (length === 0) return -1;
1830
+ const dx = direction.x / length;
1831
+ const dy = direction.y / length;
1832
+ let best = -1;
1833
+ let bestScore = Number.POSITIVE_INFINITY;
1834
+ for (let index = 0; index < nodes.capacity; index++) {
1835
+ if (index === from || nodes.alive[index] !== 1) continue;
1836
+ const vx = positions.x(index) - x;
1837
+ const vy = positions.y(index) - y;
1838
+ const along = vx * dx + vy * dy;
1839
+ if (along <= 0) continue;
1840
+ const side = Math.abs(vx * dy - vy * dx);
1841
+ if (side > along * cone) continue;
1842
+ const score = along + side * sideWeight;
1843
+ if (score < bestScore) {
1844
+ best = index;
1845
+ bestScore = score;
1846
+ }
1847
+ }
1848
+ return best;
1849
+ }
1713
1850
  const noPaint = 4294967295;
1714
1851
  const tableOffset = {
1715
1852
  nodes: 0,
@@ -1730,6 +1867,12 @@ var DirtyRange = class {
1730
1867
  return range;
1731
1868
  }
1732
1869
  };
1870
+ const bits = /* @__PURE__ */ new Float32Array(1);
1871
+ const word = new Uint32Array(bits.buffer);
1872
+ function floatWord(value) {
1873
+ bits[0] = value;
1874
+ return word[0] ?? 0;
1875
+ }
1733
1876
  function grow(array, capacity) {
1734
1877
  if (capacity <= array.length) return array;
1735
1878
  const next = new Uint32Array(Math.max(capacity, array.length * 2));
@@ -1772,6 +1915,8 @@ var StateBuffers = class {
1772
1915
  this.numbers[at + 3] = style?.label === void 0 ? -1 : style.label ? 1 : 0;
1773
1916
  this.paint[at] = style?.fill ?? 4294967295;
1774
1917
  this.paint[at + 1] = style?.stroke ?? 4294967295;
1918
+ this.paint[at + 2] = style?.outline ?? 4294967295;
1919
+ this.paint[at + 3] = style?.outlineWidth === void 0 ? noPaint : floatWord(Math.max(0, style.outlineWidth));
1775
1920
  if (kind === "nodes") this.labelFlags[bit] = this.numbers[at + 3] ?? -1;
1776
1921
  this.tableVersion++;
1777
1922
  }
@@ -2191,149 +2336,167 @@ var GlyphAtlas = class {
2191
2336
  this.generation++;
2192
2337
  }
2193
2338
  };
2194
- const primarySize = 12;
2195
- const lineHeight = 1.25;
2196
- const labelMin = 8;
2197
- const cell = 8;
2198
- const maxLabels = 4e3;
2199
- const cullMargin = 400;
2200
- var GlyphBuffer = class {
2201
- data = /* @__PURE__ */ new ArrayBuffer(8192);
2202
- count = 0;
2203
- #u32 = new Uint32Array(this.data);
2204
- #f32 = new Float32Array(this.data);
2205
- #u16 = new Uint16Array(this.data);
2206
- clear() {
2207
- this.count = 0;
2339
+ const levels = Math.log2(256) + 1;
2340
+ const maxLayers = 256;
2341
+ var ImageAtlas = class {
2342
+ #gl;
2343
+ #invalidate;
2344
+ #entries = /* @__PURE__ */ new Map();
2345
+ #free = [];
2346
+ #placeholder;
2347
+ #texture = null;
2348
+ #capacity = 0;
2349
+ #next = 0;
2350
+ #loaded = /* @__PURE__ */ new Set();
2351
+ #mipsStale = false;
2352
+ #canvas = null;
2353
+ #warnedFull = false;
2354
+ #destroyed = false;
2355
+ constructor(gl, invalidate) {
2356
+ this.#gl = gl;
2357
+ this.#invalidate = invalidate;
2358
+ this.#placeholder = gl.createTexture();
2359
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.#placeholder);
2360
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, 1, 1, 1);
2208
2361
  }
2209
- push(node, kind, line, atlasHeight) {
2210
- for (const { glyph, x } of line.glyphs) {
2211
- if ((this.count + 1) * 32 > this.data.byteLength) this.#grow();
2212
- const at = this.count * 8;
2213
- this.#u32[at] = node;
2214
- this.#u32[at + 1] = kind;
2215
- this.#f32[at + 2] = x;
2216
- this.#f32[at + 3] = glyph.top;
2217
- this.#f32[at + 4] = glyph.width;
2218
- this.#f32[at + 5] = glyph.height;
2219
- const uv = at * 2 + 12;
2220
- this.#u16[uv] = Math.round(glyph.x / atlasWidth * 65535);
2221
- this.#u16[uv + 1] = Math.round(glyph.y / atlasHeight * 65535);
2222
- this.#u16[uv + 2] = Math.round((glyph.x + glyph.width) / atlasWidth * 65535);
2223
- this.#u16[uv + 3] = Math.round((glyph.y + glyph.height) / atlasHeight * 65535);
2224
- this.count++;
2225
- }
2362
+ get texture() {
2363
+ return this.#texture ?? this.#placeholder;
2226
2364
  }
2227
- #grow() {
2228
- const data = /* @__PURE__ */ new ArrayBuffer(this.data.byteLength * 2);
2229
- new Uint8Array(data).set(new Uint8Array(this.data));
2230
- this.data = data;
2231
- this.#u32 = new Uint32Array(data);
2232
- this.#f32 = new Float32Array(data);
2233
- this.#u16 = new Uint16Array(data);
2365
+ layer(src) {
2366
+ return this.#entries.get(src)?.layer ?? -1;
2234
2367
  }
2235
- };
2236
- function labelVisible(mask, flags) {
2237
- let bits = mask >>> 0;
2238
- while (bits !== 0) {
2239
- const bit = 31 - Math.clz32(bits & -bits);
2240
- bits &= bits - 1;
2241
- const flag = flags[bit] ?? -1;
2242
- if (flag >= 0) return flag === 1;
2368
+ acquire(src) {
2369
+ const entry = this.#entries.get(src);
2370
+ if (entry) {
2371
+ entry.count++;
2372
+ return;
2373
+ }
2374
+ this.#entries.set(src, {
2375
+ count: 1,
2376
+ layer: -1
2377
+ });
2378
+ const image = new Image();
2379
+ image.crossOrigin = "anonymous";
2380
+ image.decoding = "async";
2381
+ image.src = src;
2382
+ image.decode().then(() => this.#store(src, image), () => this.#fail(src));
2383
+ }
2384
+ release(src) {
2385
+ const entry = this.#entries.get(src);
2386
+ if (!entry || --entry.count > 0) return;
2387
+ this.#entries.delete(src);
2388
+ if (entry.layer >= 0) this.#free.push(entry.layer);
2389
+ }
2390
+ takeLoaded() {
2391
+ if (this.#loaded.size === 0) return null;
2392
+ const loaded = this.#loaded;
2393
+ this.#loaded = /* @__PURE__ */ new Set();
2394
+ return loaded;
2395
+ }
2396
+ prepare() {
2397
+ if (!this.#mipsStale || !this.#texture) return;
2398
+ this.#mipsStale = false;
2399
+ const gl = this.#gl;
2400
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.#texture);
2401
+ gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
2243
2402
  }
2244
- return true;
2245
- }
2246
- var LabelLayer = class {
2247
- main = new GlyphBuffer();
2248
- highlight = new GlyphBuffer();
2249
- #occupancy = /* @__PURE__ */ new Uint8Array(0);
2250
- select(frame, atlas) {
2251
- this.main.clear();
2252
- this.highlight.clear();
2253
- const { nodes, positions, view, width, height } = frame;
2254
- const cols = Math.ceil(width / cell) + 1;
2255
- const rows = Math.ceil(height / cell) + 1;
2256
- if (this.#occupancy.length < cols * rows) this.#occupancy = new Uint8Array(cols * rows);
2257
- else this.#occupancy.fill(0, 0, cols * rows);
2258
- const occupancy = this.#occupancy;
2259
- const highlighted = new Set(frame.highlighted);
2260
- const worldPrimary = primarySize * Math.min(1, view.zoom);
2261
- let placed = 0;
2262
- const place = (index, force) => {
2263
- const label = nodes.label[index];
2264
- if (!label || nodes.alive[index] !== 1) return;
2265
- if (!labelVisible(frame.nodeMask[index] ?? 0, frame.labelFlags)) return;
2266
- const screenUnits = nodes.screenUnits[index] === 1;
2267
- const primary = screenUnits ? primarySize : worldPrimary;
2268
- if (primary < labelMin) return;
2269
- const diameter = screenUnits ? nodes.size[index] ?? 0 : (nodes.size[index] ?? 0) * view.zoom;
2270
- const radius = Math.max(diameter / 2, 1.25);
2271
- const x = (positions.x(index) - view.x) * view.zoom + width / 2;
2272
- const y = (positions.y(index) - view.y) * view.zoom + height / 2;
2273
- if (x < -400 || x > width + cullMargin) return;
2274
- if (y + radius > height || y + radius + primary * lineHeight * 2 < -3) return;
2275
- const line = atlas.line(label, "500");
2276
- const secondaryText = nodes.secondary[index];
2277
- const secondary = secondaryText ? atlas.line(secondaryText, "400") : null;
2278
- const scale = primary / 24;
2279
- const textWidth = Math.max(line.width * scale, secondary ? secondary.width * scale * 11 / 12 : 0);
2280
- const top = y + radius + 3;
2281
- const bottom = top + primary * lineHeight * (secondary ? 2 : 1);
2282
- const left = x - textWidth / 2;
2283
- const right = x + textWidth / 2;
2284
- if (right < 0 || left > width || bottom < 0 || top > height) return;
2285
- const c0 = Math.max(0, Math.floor(left / cell));
2286
- const c1 = Math.min(cols - 1, Math.floor(right / cell));
2287
- const r0 = Math.max(0, Math.floor(top / cell));
2288
- const r1 = Math.min(rows - 1, Math.floor(bottom / cell));
2289
- if (!force) {
2290
- for (let row = r0; row <= r1; row++) for (let col = c0; col <= c1; col++) if (occupancy[row * cols + col] === 1) return;
2403
+ destroy() {
2404
+ this.#destroyed = true;
2405
+ this.#gl.deleteTexture(this.#placeholder);
2406
+ if (this.#texture) this.#gl.deleteTexture(this.#texture);
2407
+ }
2408
+ #fail(src) {
2409
+ if (this.#destroyed) return;
2410
+ if (dev && this.#entries.has(src)) console.warn(`graph: cannot load the image ${src}`);
2411
+ }
2412
+ #store(src, image) {
2413
+ const entry = this.#entries.get(src);
2414
+ if (this.#destroyed || !entry || entry.layer >= 0) return;
2415
+ const layer = this.#free.pop() ?? this.#next;
2416
+ if (layer >= this.#capacity && !this.#grow()) {
2417
+ if (dev && !this.#warnedFull) {
2418
+ this.#warnedFull = true;
2419
+ console.warn(`graph: more than ${maxLayers} distinct images, the others are not drawn`);
2291
2420
  }
2292
- for (let row = r0; row <= r1; row++) occupancy.fill(1, row * cols + c0, row * cols + c1 + 1);
2293
- const target = force ? this.highlight : this.main;
2294
- target.push(index, 0, line, atlas.height);
2295
- if (secondary) target.push(index, 1, secondary, atlas.height);
2296
- placed++;
2297
- };
2298
- for (const index of highlighted) place(index, true);
2299
- if (worldPrimary < labelMin && !frame.screenLabels) return;
2300
- const xy = positions.xy;
2301
- const order = frame.order;
2302
- const left = view.x - (width / 2 + cullMargin) / view.zoom;
2303
- const right = view.x + (width / 2 + cullMargin) / view.zoom;
2304
- const largest = nodes.size[order[0] ?? 0] ?? 0;
2305
- const reach = cullMargin + (frame.screenLabels ? largest : largest * view.zoom) / 2;
2306
- const top = view.y - (height / 2 + reach) / view.zoom;
2307
- const bottom = view.y + height / 2 / view.zoom;
2308
- for (let at = 0; at < frame.orderLength && placed < maxLabels; at++) {
2309
- const index = order[at] ?? 0;
2310
- const x = xy[index * 2] ?? 0;
2311
- const y = xy[index * 2 + 1] ?? 0;
2312
- if (x < left || x > right || y < top || y > bottom) continue;
2313
- if (!highlighted.has(index)) place(index, false);
2421
+ return;
2422
+ }
2423
+ if (layer === this.#next) this.#next++;
2424
+ const canvas = this.#draw(image);
2425
+ const gl = this.#gl;
2426
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.#texture);
2427
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
2428
+ try {
2429
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, 256, 256, 1, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
2430
+ } catch {
2431
+ this.#free.push(layer);
2432
+ this.#fail(src);
2433
+ return;
2434
+ } finally {
2435
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
2436
+ }
2437
+ entry.layer = layer;
2438
+ this.#mipsStale = true;
2439
+ this.#loaded.add(src);
2440
+ this.#invalidate();
2441
+ }
2442
+ #draw(image) {
2443
+ this.#canvas ??= Object.assign(document.createElement("canvas"), {
2444
+ width: 256,
2445
+ height: 256
2446
+ });
2447
+ const context = this.#canvas.getContext("2d");
2448
+ if (context) {
2449
+ context.clearRect(0, 0, 256, 256);
2450
+ const width = image.naturalWidth;
2451
+ const height = image.naturalHeight;
2452
+ const scale = width > 0 && height > 0 ? 256 / Math.min(width, height) : 0;
2453
+ const drawnWidth = scale > 0 ? width * scale : 256;
2454
+ const drawnHeight = scale > 0 ? height * scale : 256;
2455
+ context.drawImage(image, (256 - drawnWidth) / 2, (256 - drawnHeight) / 2, drawnWidth, drawnHeight);
2314
2456
  }
2457
+ return this.#canvas;
2315
2458
  }
2316
- };
2317
- function sortBySize(nodes, order) {
2318
- let length = 0;
2319
- let screenLabels = false;
2320
- for (let index = 0; index < nodes.capacity; index++) if (nodes.alive[index] === 1 && nodes.label[index]) {
2321
- order[length++] = index;
2322
- if (nodes.screenUnits[index] === 1) screenLabels = true;
2459
+ #grow() {
2460
+ if (this.#capacity >= maxLayers) return false;
2461
+ const gl = this.#gl;
2462
+ const capacity = Math.min(maxLayers, Math.max(4, this.#capacity * 2));
2463
+ const texture = gl.createTexture();
2464
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture);
2465
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, levels, gl.RGBA8, 256, 256, capacity);
2466
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
2467
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2468
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2469
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2470
+ const previous = this.#texture;
2471
+ if (previous) {
2472
+ const framebuffer = gl.createFramebuffer();
2473
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer);
2474
+ for (let layer = 0; layer < this.#capacity; layer++) {
2475
+ gl.framebufferTextureLayer(gl.READ_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, previous, 0, layer);
2476
+ gl.copyTexSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, 0, 0, 256, 256);
2477
+ }
2478
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
2479
+ gl.deleteFramebuffer(framebuffer);
2480
+ gl.deleteTexture(previous);
2481
+ }
2482
+ this.#texture = texture;
2483
+ this.#capacity = capacity;
2484
+ this.#mipsStale = true;
2485
+ return true;
2323
2486
  }
2324
- const sizes = nodes.size;
2325
- order.subarray(0, length).sort((a, b) => (sizes[b] ?? 0) - (sizes[a] ?? 0));
2326
- return {
2327
- length,
2328
- screenLabels
2329
- };
2330
- }
2331
- // src/webgl/shaders.ts
2487
+ };
2488
+ const badgeFont = .66;
2489
+ const badgeRing = 1.5;
2490
+ const platePadX = .45;
2491
+ const plateHalfOne = .75;
2492
+ const plateHalfTwo = 1.32;
2493
+ const plateRadius = .25;
2332
2494
  const header = `#version 300 es
2333
2495
  precision highp float;
2334
2496
  precision highp int;
2335
2497
  precision highp sampler2D;
2336
2498
  precision highp usampler2D;
2499
+ precision highp sampler2DArray;
2337
2500
  layout(std140) uniform Frame {
2338
2501
  vec2 center;
2339
2502
  vec2 viewport;
@@ -2353,7 +2516,7 @@ layout(std140) uniform States {
2353
2516
  uniform int layerSlot;
2354
2517
  uniform int overlay;
2355
2518
  uniform sampler2D palette;
2356
- const uint highlightMask = 3u;
2519
+ const uint highlightMask = 7u;
2357
2520
  const uint none = 0xffffffffu;
2358
2521
  const vec4 hidden = vec4(2.0, 2.0, 2.0, 1.0);
2359
2522
  vec2 toScreen(vec2 world) { return (world - center) * zoom + viewport * 0.5; }
@@ -2362,7 +2525,7 @@ vec4 toClip(vec2 screen) {
2362
2525
  }
2363
2526
  ivec2 texel(uint index) { return ivec2(int(index & 1023u), int(index >> 10u)); }
2364
2527
  vec4 paletteColor(uint id) { return texelFetch(palette, ivec2(int(id & 255u), int(id >> 8u)), 0); }
2365
- struct Look { float opacity; float strokeWidth; float halo; float label; uint fill; uint stroke; };
2528
+ struct Look { float opacity; float strokeWidth; float halo; float label; uint fill; uint stroke; uint outline; float outlineWidth; };
2366
2529
  Look resolve(uint mask, Look look, int table) {
2367
2530
  bool opacity = false;
2368
2531
  bool strokeWidth = false;
@@ -2370,6 +2533,8 @@ Look resolve(uint mask, Look look, int table) {
2370
2533
  bool label = false;
2371
2534
  bool fill = false;
2372
2535
  bool stroke = false;
2536
+ bool outline = false;
2537
+ bool outlineWidth = false;
2373
2538
  float base = look.opacity;
2374
2539
  for (int bit = 0; bit < 32; bit++) {
2375
2540
  if (mask == 0u) break;
@@ -2384,6 +2549,8 @@ Look resolve(uint mask, Look look, int table) {
2384
2549
  if (!label && numbers.w >= 0.0) { look.label = numbers.w; label = true; }
2385
2550
  if (!fill && paint.x != none) { look.fill = paint.x; fill = true; }
2386
2551
  if (!stroke && paint.y != none) { look.stroke = paint.y; stroke = true; }
2552
+ if (!outline && paint.z != none) { look.outline = paint.z; outline = true; }
2553
+ if (!outlineWidth && paint.w != none) { look.outlineWidth = uintBitsToFloat(paint.w); outlineWidth = true; }
2387
2554
  }
2388
2555
  return look;
2389
2556
  }
@@ -2393,11 +2560,23 @@ uniform sampler2D nodePosition;
2393
2560
  uniform sampler2D nodeStyle;
2394
2561
  uniform usampler2D nodePaint;
2395
2562
  uniform usampler2D nodeState;
2563
+ uniform usampler2D nodeDecor;
2564
+ Look nodeLook(ivec2 t, vec4 style, uvec4 paint, uint mask) {
2565
+ float outlineWidth = uintBitsToFloat(texelFetch(nodeDecor, t, 0).w);
2566
+ return resolve(mask, Look(style.z, style.y, 0.0, 1.0, paint.x, paint.y, paint.w, outlineWidth), 0);
2567
+ }
2396
2568
  float nodeDiameter(vec4 style) {
2397
2569
  uint flags = uint(style.w + 0.5);
2398
2570
  return (flags & 4u) != 0u ? style.x : style.x * zoom;
2399
2571
  }
2400
2572
  float nodeRadius(vec4 style) { return max(nodeDiameter(style) * 0.5, 1.25); }
2573
+ float badgeHeight(vec4 style) { return min(nodeDiameter(style) * 0.55, ${18 .toFixed(1)}); }
2574
+ vec2 badgeCenter(vec2 xy, vec4 style, uint corner) {
2575
+ uint shape = uint(style.w + 0.5) & 3u;
2576
+ float reach = shape == 0u ? 0.70710678 : shape == 1u ? 0.86 : 0.6;
2577
+ vec2 side = vec2((corner & 1u) != 0u ? 1.0 : -1.0, (corner & 2u) != 0u ? 1.0 : -1.0);
2578
+ return toScreen(xy) + side * reach * nodeRadius(style);
2579
+ }
2401
2580
  `;
2402
2581
  const edgeData = `
2403
2582
  uniform usampler2D edgeTopology;
@@ -2408,12 +2587,17 @@ const nodeVertex = `${header}${nodeData}
2408
2587
  in vec2 corner;
2409
2588
  in uint instance;
2410
2589
  out vec2 vLocal;
2590
+ flat out float vImageLayer;
2591
+ flat out float vImageScale;
2592
+ flat out vec4 vImageColor;
2411
2593
  out float vRadius;
2412
2594
  out float vStrokeWidth;
2413
2595
  out float vHaloWidth;
2596
+ out float vOutlineWidth;
2414
2597
  flat out uint vShape;
2415
2598
  flat out vec4 vFill;
2416
2599
  flat out vec4 vStroke;
2600
+ flat out vec4 vOutline;
2417
2601
  flat out float vOpacity;
2418
2602
  void main() {
2419
2603
  ivec2 t = texel(instance);
@@ -2426,29 +2610,42 @@ void main() {
2426
2610
  gl_Position = hidden;
2427
2611
  return;
2428
2612
  }
2429
- Look look = resolve(mask, Look(style.z, style.y, 0.0, 1.0, paint.x, paint.y), 0);
2613
+ Look look = nodeLook(t, style, paint, mask);
2430
2614
  vec2 xy = texelFetch(nodePosition, t, 0).xy;
2431
2615
  float radius = nodeRadius(style);
2432
2616
  float halo = look.halo > 0.5 ? haloWidth : 0.0;
2433
- vLocal = corner * (radius + halo + 1.5);
2617
+ float outline = max(look.outlineWidth, 0.0);
2618
+ vLocal = corner * (radius + outline + halo + 1.5);
2434
2619
  vRadius = radius;
2435
2620
  vStrokeWidth = min(look.strokeWidth, radius * 0.5);
2436
2621
  vHaloWidth = halo;
2622
+ vOutlineWidth = outline;
2623
+ vOutline = paletteColor(look.outline);
2437
2624
  vShape = flags & 3u;
2438
2625
  vFill = paletteColor(look.fill);
2439
2626
  vStroke = paletteColor(look.stroke);
2440
2627
  vOpacity = look.opacity;
2628
+ uvec4 image = texelFetch(nodeDecor, t, 0);
2629
+ vImageLayer = float(image.x) - 1.0;
2630
+ vImageScale = max(uintBitsToFloat(image.z), 0.001);
2631
+ vImageColor = image.y == none ? vec4(-1.0) : paletteColor(image.y);
2441
2632
  gl_Position = toClip(toScreen(xy) + vLocal);
2442
2633
  }
2443
2634
  `;
2444
2635
  const nodeFragment = `${header}
2636
+ uniform sampler2DArray images;
2445
2637
  in vec2 vLocal;
2638
+ flat in float vImageLayer;
2639
+ flat in float vImageScale;
2640
+ flat in vec4 vImageColor;
2446
2641
  in float vRadius;
2447
2642
  in float vStrokeWidth;
2448
2643
  in float vHaloWidth;
2644
+ in float vOutlineWidth;
2449
2645
  flat in uint vShape;
2450
2646
  flat in vec4 vFill;
2451
2647
  flat in vec4 vStroke;
2648
+ flat in vec4 vOutline;
2452
2649
  flat in float vOpacity;
2453
2650
  out vec4 fragColor;
2454
2651
  float shapeDistance(vec2 p, float r) {
@@ -2464,11 +2661,23 @@ void main() {
2464
2661
  float d = shapeDistance(vLocal, vRadius);
2465
2662
  float inside = 1.0 - smoothstep(-aa, aa, d);
2466
2663
  float ring = vStrokeWidth > 0.0 ? smoothstep(-vStrokeWidth - aa, -vStrokeWidth + aa, d) : 0.0;
2467
- vec4 color = mix(vFill, vStroke, ring);
2468
- float alpha = color.a * inside * vOpacity;
2469
- vec4 result = vec4(color.rgb * alpha, alpha);
2664
+ vec4 fill = vec4(vFill.rgb * vFill.a, vFill.a);
2665
+ if (vImageLayer >= 0.0) {
2666
+ vec2 uv = vLocal / (vRadius * 2.0 * vImageScale) + 0.5;
2667
+ vec4 picture = texture(images, vec3(uv, vImageLayer));
2668
+ vec2 within = step(vec2(0.0), uv) * step(uv, vec2(1.0));
2669
+ picture *= within.x * within.y;
2670
+ if (vImageColor.a >= 0.0) picture = vec4(vImageColor.rgb * vImageColor.a, vImageColor.a) * picture.a;
2671
+ fill = picture + fill * (1.0 - picture.a);
2672
+ }
2673
+ vec4 color = mix(fill, vec4(vStroke.rgb * vStroke.a, vStroke.a), ring);
2674
+ vec4 result = color * inside * vOpacity;
2675
+ if (vOutlineWidth > 0.0) {
2676
+ float outer = 1.0 - smoothstep(vOutlineWidth - aa, vOutlineWidth + aa, d);
2677
+ result = mix(vec4(vOutline.rgb * vOutline.a, vOutline.a), color, inside) * outer * vOpacity;
2678
+ }
2470
2679
  if (vHaloWidth > 0.0) {
2471
- float h = haloColor.a * (1.0 - smoothstep(0.0, vHaloWidth, d)) * vOpacity;
2680
+ float h = haloColor.a * (1.0 - smoothstep(0.0, vHaloWidth, d - vOutlineWidth)) * vOpacity;
2472
2681
  result += vec4(haloColor.rgb * h, h) * (1.0 - result.a);
2473
2682
  }
2474
2683
  if (result.a < 0.002) discard;
@@ -2504,7 +2713,7 @@ void main() {
2504
2713
  gl_Position = hidden;
2505
2714
  return;
2506
2715
  }
2507
- Look look = resolve(mask, Look(style.y, style.x, 0.0, 1.0, none, topology.z), 32);
2716
+ Look look = resolve(mask, Look(style.y, style.x, 0.0, 1.0, none, topology.z, none, 0.0), 32);
2508
2717
  ivec2 s = texel(topology.x);
2509
2718
  ivec2 e = texel(topology.y);
2510
2719
  vec2 ps = toScreen(texelFetch(nodePosition, s, 0).xy);
@@ -2606,7 +2815,7 @@ void main() {
2606
2815
  gl_Position = hidden;
2607
2816
  return;
2608
2817
  }
2609
- Look look = resolve(mask, Look(style.y, style.x, 0.0, 1.0, none, topology.z), 32);
2818
+ Look look = resolve(mask, Look(style.y, style.x, 0.0, 1.0, none, topology.z, none, 0.0), 32);
2610
2819
  ivec2 s = texel(topology.x);
2611
2820
  ivec2 e = texel(topology.y);
2612
2821
  vec2 a = toScreen(texelFetch(nodePosition, s, 0).xy);
@@ -2749,28 +2958,63 @@ in vec4 glyphRect;
2749
2958
  in vec4 glyphUv;
2750
2959
  out vec2 vUv;
2751
2960
  out float vScale;
2752
- flat out uint vKind;
2961
+ flat out vec4 vInk;
2962
+ flat out float vHalo;
2963
+ flat out vec4 vPlate;
2753
2964
  flat out float vOpacity;
2754
2965
  void main() {
2966
+ vPlate = vec4(0.0);
2755
2967
  ivec2 t = texel(glyphNode);
2756
2968
  vec4 style = texelFetch(nodeStyle, t, 0);
2757
2969
  uvec4 paint = texelFetch(nodePaint, t, 0);
2758
2970
  uint flags = uint(style.w + 0.5);
2759
2971
  uint mask = texelFetch(nodeState, t, 0).r;
2760
- Look look = resolve(mask, Look(style.z, style.y, 0.0, 1.0, paint.x, paint.y), 0);
2972
+ Look look = nodeLook(t, style, paint, mask);
2973
+ vUv = mix(glyphUv.xy, glyphUv.zw, corner);
2974
+ vOpacity = look.opacity;
2975
+ uint kind = glyphKind & 3u;
2976
+ if (kind == 2u) {
2977
+ bool highlighted = (mask & highlightMask) != 0u;
2978
+ float height = badgeHeight(style);
2979
+ if ((flags & 8u) == 0u || highlighted != (overlay == 1) || height < ${12 .toFixed(1)}) {
2980
+ gl_Position = hidden;
2981
+ return;
2982
+ }
2983
+ float font = height * ${badgeFont.toFixed(2)};
2984
+ vec2 center = badgeCenter(texelFetch(nodePosition, t, 0).xy, style, (glyphKind >> 2u) & 3u);
2985
+ vScale = font / atlasFontSize;
2986
+ vInk = paletteColor(glyphKind >> 4u);
2987
+ vHalo = 0.0;
2988
+ gl_Position = toClip(center - vec2(0.0, font * 0.65) + (glyphRect.xy + corner * glyphRect.zw) * vScale);
2989
+ return;
2990
+ }
2761
2991
  float primary = (flags & 4u) != 0u ? 12.0 : 12.0 * min(1.0, zoom);
2762
2992
  if ((flags & 8u) == 0u || look.label < 0.5 || primary < labelMin) {
2763
2993
  gl_Position = hidden;
2764
2994
  return;
2765
2995
  }
2766
- float size = glyphKind == 0u ? primary : primary * 11.0 / 12.0;
2996
+ float below = nodeRadius(style) + max(look.outlineWidth, 0.0) + 3.0;
2997
+ vec2 top = toScreen(texelFetch(nodePosition, t, 0).xy) + vec2(0.0, below);
2998
+ if (kind == 3u) {
2999
+ bool two = glyphRect.y > 1.5;
3000
+ vec2 extent = vec2(
3001
+ glyphRect.x * primary / atlasFontSize * 0.5 + primary * ${platePadX.toFixed(2)},
3002
+ primary * (two ? ${plateHalfTwo.toFixed(2)} : ${plateHalfOne.toFixed(2)})
3003
+ );
3004
+ vUv = (corner * 2.0 - 1.0) * (extent + 1.0);
3005
+ vPlate = vec4(extent, primary * ${plateRadius.toFixed(2)}, 1.0);
3006
+ vScale = 1.0;
3007
+ vInk = paletteColor(glyphKind >> 4u);
3008
+ vHalo = 0.0;
3009
+ gl_Position = toClip(top + vec2(0.0, primary * (two ? ${plateHalfTwo.toFixed(2)} : ${plateHalfOne.toFixed(2)})) + vUv);
3010
+ return;
3011
+ }
3012
+ float size = kind == 0u ? primary : primary * 11.0 / 12.0;
2767
3013
  float scale = size / atlasFontSize;
2768
- float below = nodeRadius(style) + 3.0 + (glyphKind == 1u ? primary * 1.25 : 0.0);
2769
- vec2 anchor = toScreen(texelFetch(nodePosition, t, 0).xy) + vec2(0.0, below);
2770
- vUv = mix(glyphUv.xy, glyphUv.zw, corner);
3014
+ vec2 anchor = top + vec2(0.0, kind == 1u ? primary * 1.25 : 0.0);
2771
3015
  vScale = scale;
2772
- vKind = glyphKind;
2773
- vOpacity = look.opacity;
3016
+ vInk = kind == 0u ? labelInk : labelMuted;
3017
+ vHalo = (glyphKind & 4u) != 0u ? 0.0 : 1.0;
2774
3018
  gl_Position = toClip(anchor + (glyphRect.xy + corner * glyphRect.zw) * scale);
2775
3019
  }
2776
3020
  `;
@@ -2778,21 +3022,88 @@ const labelFragment = `${header}
2778
3022
  uniform sampler2D atlas;
2779
3023
  in vec2 vUv;
2780
3024
  in float vScale;
2781
- flat in uint vKind;
3025
+ flat in vec4 vInk;
3026
+ flat in float vHalo;
3027
+ flat in vec4 vPlate;
2782
3028
  flat in float vOpacity;
2783
3029
  out vec4 fragColor;
2784
3030
  void main() {
3031
+ if (vPlate.w > 0.5) {
3032
+ vec2 q = abs(vUv) - vPlate.xy + vPlate.z;
3033
+ float edge = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - vPlate.z;
3034
+ float aa = 0.5 / pixelRatio + 0.25;
3035
+ float cover = vInk.a * (1.0 - smoothstep(-aa, aa, edge)) * vOpacity;
3036
+ if (cover < 0.002) discard;
3037
+ fragColor = vec4(vInk.rgb * cover, cover);
3038
+ return;
3039
+ }
2785
3040
  float d = texture(atlas, vUv).r;
2786
3041
  float gamma = 0.08 / max(vScale * pixelRatio, 0.05);
2787
3042
  float ink = smoothstep(0.75 - gamma, 0.75 + gamma, d);
2788
- float halo = smoothstep(0.55 - gamma, 0.55 + gamma, d);
2789
- vec4 inkColor = vKind == 0u ? labelInk : labelMuted;
2790
- vec3 rgb = mix(labelHalo.rgb, inkColor.rgb, ink);
2791
- float alpha = max(halo * labelHalo.a, ink * inkColor.a) * vOpacity;
3043
+ float halo = smoothstep(0.55 - gamma, 0.55 + gamma, d) * vHalo;
3044
+ vec3 rgb = vHalo > 0.5 ? mix(labelHalo.rgb, vInk.rgb, ink) : vInk.rgb;
3045
+ float alpha = max(halo * labelHalo.a, ink * vInk.a) * vOpacity;
2792
3046
  if (alpha < 0.002) discard;
2793
3047
  fragColor = vec4(rgb * alpha, alpha);
2794
3048
  }
2795
3049
  `;
3050
+ const badgeVertex = `${header}${nodeData}
3051
+ in vec2 corner;
3052
+ in uint badgeNode;
3053
+ in uint badgeInfo;
3054
+ in uint badgeFill;
3055
+ in uint badgeRingColor;
3056
+ in float badgeText;
3057
+ out vec2 vLocal;
3058
+ flat out vec2 vHalf;
3059
+ flat out vec4 vFill;
3060
+ flat out vec4 vRing;
3061
+ flat out float vOpacity;
3062
+ void main() {
3063
+ ivec2 t = texel(badgeNode);
3064
+ vec4 style = texelFetch(nodeStyle, t, 0);
3065
+ uvec4 paint = texelFetch(nodePaint, t, 0);
3066
+ uint flags = uint(style.w + 0.5);
3067
+ uint mask = texelFetch(nodeState, t, 0).r;
3068
+ bool highlighted = (mask & highlightMask) != 0u;
3069
+ float height = badgeHeight(style);
3070
+ if ((flags & 8u) == 0u || highlighted != (overlay == 1) || height < ${10 .toFixed(1)}) {
3071
+ gl_Position = hidden;
3072
+ return;
3073
+ }
3074
+ Look look = nodeLook(t, style, paint, mask);
3075
+ bool text = (badgeInfo & 4u) != 0u && height >= ${12 .toFixed(1)};
3076
+ float font = height * ${badgeFont.toFixed(2)};
3077
+ float width = text ? max(height, badgeText * font / ${24 .toFixed(1)} + height * 0.6) : height;
3078
+ vec2 center = badgeCenter(texelFetch(nodePosition, t, 0).xy, style, badgeInfo & 3u);
3079
+ vHalf = vec2(width, height) * 0.5;
3080
+ vLocal = corner * (vHalf + ${badgeRing.toFixed(1)} + 1.0);
3081
+ vFill = paletteColor(badgeFill);
3082
+ vRing = paletteColor(badgeRingColor);
3083
+ vOpacity = look.opacity;
3084
+ gl_Position = toClip(center + vLocal);
3085
+ }
3086
+ `;
3087
+ const badgeFragment = `${header}
3088
+ in vec2 vLocal;
3089
+ flat in vec2 vHalf;
3090
+ flat in vec4 vFill;
3091
+ flat in vec4 vRing;
3092
+ flat in float vOpacity;
3093
+ out vec4 fragColor;
3094
+ void main() {
3095
+ float aa = 0.5 / pixelRatio + 0.25;
3096
+ vec2 q = vec2(max(abs(vLocal.x) - (vHalf.x - vHalf.y), 0.0), vLocal.y);
3097
+ float d = length(q) - vHalf.y;
3098
+ float inner = 1.0 - smoothstep(-aa, aa, d);
3099
+ float outer = 1.0 - smoothstep(${badgeRing.toFixed(1)} - aa, ${badgeRing.toFixed(1)} + aa, d);
3100
+ vec4 fill = vec4(vFill.rgb * vFill.a, vFill.a);
3101
+ vec4 ring = vec4(vRing.rgb * vRing.a, vRing.a);
3102
+ vec4 result = mix(ring, fill, inner) * outer * vOpacity;
3103
+ if (result.a < 0.002) discard;
3104
+ fragColor = result;
3105
+ }
3106
+ `;
2796
3107
  const cornerNode = new Float32Array([
2797
3108
  -1,
2798
3109
  -1,
@@ -2872,6 +3183,166 @@ const cornerLabel = new Float32Array([
2872
3183
  1,
2873
3184
  1
2874
3185
  ]);
3186
+ const primarySize = 12;
3187
+ const lineHeight = 1.25;
3188
+ const labelMin = 8;
3189
+ const cell = 8;
3190
+ const maxLabels = 4e3;
3191
+ const cullMargin = 400;
3192
+ const forcedStates = 6;
3193
+ var GlyphBuffer = class {
3194
+ data = /* @__PURE__ */ new ArrayBuffer(8192);
3195
+ count = 0;
3196
+ #u32 = new Uint32Array(this.data);
3197
+ #f32 = new Float32Array(this.data);
3198
+ #u16 = new Uint16Array(this.data);
3199
+ clear() {
3200
+ this.count = 0;
3201
+ }
3202
+ push(node, kind, line, atlasHeight) {
3203
+ for (const { glyph, x } of line.glyphs) {
3204
+ if ((this.count + 1) * 32 > this.data.byteLength) this.#grow();
3205
+ const at = this.count * 8;
3206
+ this.#u32[at] = node;
3207
+ this.#u32[at + 1] = kind;
3208
+ this.#f32[at + 2] = x;
3209
+ this.#f32[at + 3] = glyph.top;
3210
+ this.#f32[at + 4] = glyph.width;
3211
+ this.#f32[at + 5] = glyph.height;
3212
+ const uv = at * 2 + 12;
3213
+ this.#u16[uv] = Math.round(glyph.x / atlasWidth * 65535);
3214
+ this.#u16[uv + 1] = Math.round(glyph.y / atlasHeight * 65535);
3215
+ this.#u16[uv + 2] = Math.round((glyph.x + glyph.width) / atlasWidth * 65535);
3216
+ this.#u16[uv + 3] = Math.round((glyph.y + glyph.height) / atlasHeight * 65535);
3217
+ this.count++;
3218
+ }
3219
+ }
3220
+ pushPlate(node, color, width, lines) {
3221
+ if ((this.count + 1) * 32 > this.data.byteLength) this.#grow();
3222
+ const at = this.count * 8;
3223
+ this.#u32[at] = node;
3224
+ this.#u32[at + 1] = 3 | color << 4;
3225
+ this.#f32[at + 2] = width;
3226
+ this.#f32[at + 3] = lines;
3227
+ this.#f32[at + 4] = 0;
3228
+ this.#f32[at + 5] = 0;
3229
+ this.#u32[at + 6] = 0;
3230
+ this.#u32[at + 7] = 0;
3231
+ this.count++;
3232
+ }
3233
+ #grow() {
3234
+ const data = /* @__PURE__ */ new ArrayBuffer(this.data.byteLength * 2);
3235
+ new Uint8Array(data).set(new Uint8Array(this.data));
3236
+ this.data = data;
3237
+ this.#u32 = new Uint32Array(data);
3238
+ this.#f32 = new Float32Array(data);
3239
+ this.#u16 = new Uint16Array(data);
3240
+ }
3241
+ };
3242
+ function labelVisible(mask, flags) {
3243
+ let bits = mask >>> 0;
3244
+ while (bits !== 0) {
3245
+ const bit = 31 - Math.clz32(bits & -bits);
3246
+ bits &= bits - 1;
3247
+ const flag = flags[bit] ?? -1;
3248
+ if (flag >= 0) return flag === 1;
3249
+ }
3250
+ return true;
3251
+ }
3252
+ var LabelLayer = class {
3253
+ main = new GlyphBuffer();
3254
+ highlight = new GlyphBuffer();
3255
+ #occupancy = /* @__PURE__ */ new Uint8Array(0);
3256
+ select(frame, atlas) {
3257
+ this.main.clear();
3258
+ this.highlight.clear();
3259
+ const { nodes, positions, view, width, height } = frame;
3260
+ const cols = Math.ceil(width / cell) + 1;
3261
+ const rows = Math.ceil(height / cell) + 1;
3262
+ if (this.#occupancy.length < cols * rows) this.#occupancy = new Uint8Array(cols * rows);
3263
+ else this.#occupancy.fill(0, 0, cols * rows);
3264
+ const occupancy = this.#occupancy;
3265
+ const highlighted = new Set(frame.highlighted);
3266
+ const worldPrimary = primarySize * Math.min(1, view.zoom);
3267
+ let placed = 0;
3268
+ const forced = (index) => ((frame.nodeMask[index] ?? 0) & forcedStates) !== 0;
3269
+ const place = (index, highlight) => {
3270
+ const force = highlight && forced(index);
3271
+ const label = nodes.label[index];
3272
+ if (!label || nodes.alive[index] !== 1) return;
3273
+ if (!labelVisible(frame.nodeMask[index] ?? 0, frame.labelFlags)) return;
3274
+ const screenUnits = nodes.screenUnits[index] === 1;
3275
+ const primary = screenUnits ? primarySize : worldPrimary;
3276
+ if (primary < labelMin) return;
3277
+ const diameter = screenUnits ? nodes.size[index] ?? 0 : (nodes.size[index] ?? 0) * view.zoom;
3278
+ const radius = Math.max(diameter / 2, 1.25) + (nodes.outlineWidth[index] ?? 0);
3279
+ const x = (positions.x(index) - view.x) * view.zoom + width / 2;
3280
+ const y = (positions.y(index) - view.y) * view.zoom + height / 2;
3281
+ if (x < -400 || x > width + cullMargin) return;
3282
+ if (y + radius > height || y + radius + primary * lineHeight * 3 < -3) return;
3283
+ const line = atlas.line(label, "500");
3284
+ const secondaryText = nodes.secondary[index];
3285
+ const secondary = secondaryText ? atlas.line(secondaryText, "400") : null;
3286
+ const scale = primary / 24;
3287
+ const textUnits = Math.max(line.width, secondary ? secondary.width * 11 / 12 : 0);
3288
+ const background = nodes.labelBackground[index] ?? 4294967295;
3289
+ const plated = background !== noColor;
3290
+ const pad = plated ? primary * platePadX : 0;
3291
+ const lines = secondary ? 2 : 1;
3292
+ const top = y + radius + 3;
3293
+ const bottom = top + (plated ? primary * 2 * (secondary ? plateHalfTwo : plateHalfOne) : primary * lineHeight * lines);
3294
+ const left = x - textUnits * scale / 2 - pad;
3295
+ const right = x + textUnits * scale / 2 + pad;
3296
+ if (right < 0 || left > width || bottom < 0 || top > height) return;
3297
+ const c0 = Math.max(0, Math.floor(left / cell));
3298
+ const c1 = Math.min(cols - 1, Math.floor(right / cell));
3299
+ const r0 = Math.max(0, Math.floor(top / cell));
3300
+ const r1 = Math.min(rows - 1, Math.floor(bottom / cell));
3301
+ if (!force) {
3302
+ for (let row = r0; row <= r1; row++) for (let col = c0; col <= c1; col++) if (occupancy[row * cols + col] === 1) return;
3303
+ }
3304
+ for (let row = r0; row <= r1; row++) occupancy.fill(1, row * cols + c0, row * cols + c1 + 1);
3305
+ const target = highlight ? this.highlight : this.main;
3306
+ const plate = plated ? 4 : 0;
3307
+ if (plated) target.pushPlate(index, background, textUnits, lines);
3308
+ target.push(index, plate, line, atlas.height);
3309
+ if (secondary) target.push(index, 1 | plate, secondary, atlas.height);
3310
+ placed++;
3311
+ };
3312
+ for (const index of highlighted) if (forced(index)) place(index, true);
3313
+ for (const index of highlighted) if (!forced(index)) place(index, true);
3314
+ if (worldPrimary < labelMin && !frame.screenLabels) return;
3315
+ const xy = positions.xy;
3316
+ const order = frame.order;
3317
+ const left = view.x - (width / 2 + cullMargin) / view.zoom;
3318
+ const right = view.x + (width / 2 + cullMargin) / view.zoom;
3319
+ const largest = nodes.size[order[0] ?? 0] ?? 0;
3320
+ const reach = cullMargin + (frame.screenLabels ? largest : largest * view.zoom) / 2;
3321
+ const top = view.y - (height / 2 + reach) / view.zoom;
3322
+ const bottom = view.y + height / 2 / view.zoom;
3323
+ for (let at = 0; at < frame.orderLength && placed < maxLabels; at++) {
3324
+ const index = order[at] ?? 0;
3325
+ const x = xy[index * 2] ?? 0;
3326
+ const y = xy[index * 2 + 1] ?? 0;
3327
+ if (x < left || x > right || y < top || y > bottom) continue;
3328
+ if (!highlighted.has(index)) place(index, false);
3329
+ }
3330
+ }
3331
+ };
3332
+ function sortBySize(nodes, order) {
3333
+ let length = 0;
3334
+ let screenLabels = false;
3335
+ for (let index = 0; index < nodes.capacity; index++) if (nodes.alive[index] === 1 && nodes.label[index]) {
3336
+ order[length++] = index;
3337
+ if (nodes.screenUnits[index] === 1) screenLabels = true;
3338
+ }
3339
+ const sizes = nodes.size;
3340
+ order.subarray(0, length).sort((a, b) => (sizes[b] ?? 0) - (sizes[a] ?? 0));
3341
+ return {
3342
+ length,
3343
+ screenLabels
3344
+ };
3345
+ }
2875
3346
  // src/webgl/renderer.ts
2876
3347
  const blocks = {
2877
3348
  Frame: 0,
@@ -2879,6 +3350,7 @@ const blocks = {
2879
3350
  };
2880
3351
  const paletteWidth = 256;
2881
3352
  const labelInterval = 120;
3353
+ const badgeStride = 20;
2882
3354
  const units = {
2883
3355
  palette: 0,
2884
3356
  nodePosition: 1,
@@ -2888,7 +3360,9 @@ const units = {
2888
3360
  edgeTopology: 5,
2889
3361
  edgeStyle: 6,
2890
3362
  edgeState: 7,
2891
- atlas: 8
3363
+ atlas: 8,
3364
+ images: 9,
3365
+ nodeDecor: 10
2892
3366
  };
2893
3367
  function iota(length) {
2894
3368
  const values = new Uint32Array(length);
@@ -2898,7 +3372,7 @@ function iota(length) {
2898
3372
  function rgba(color) {
2899
3373
  return color.map((channel) => channel / 255);
2900
3374
  }
2901
- function createWebglRenderer(canvas) {
3375
+ function createWebglRenderer(canvas, invalidate = () => {}) {
2902
3376
  const context = canvas.getContext("webgl2", {
2903
3377
  alpha: true,
2904
3378
  antialias: false,
@@ -2915,6 +3389,7 @@ function createWebglRenderer(canvas) {
2915
3389
  const nodeStyle = new DataTexture(gl, format.rgba32f, f32);
2916
3390
  const nodePaint = new DataTexture(gl, format.rgba32ui, u32);
2917
3391
  const nodeState = new DataTexture(gl, format.r32ui, u32);
3392
+ const nodeDecor = new DataTexture(gl, format.rgba32ui, u32);
2918
3393
  const edgeTopology = new DataTexture(gl, format.rgba32ui, u32);
2919
3394
  const edgeStyle = new DataTexture(gl, format.rgba32f, f32);
2920
3395
  const edgeState = new DataTexture(gl, format.r32ui, u32);
@@ -2922,8 +3397,12 @@ function createWebglRenderer(canvas) {
2922
3397
  nodePosition,
2923
3398
  nodeStyle,
2924
3399
  nodePaint,
2925
- nodeState
3400
+ nodeState,
3401
+ nodeDecor
2926
3402
  ];
3403
+ const images = new ImageAtlas(gl, invalidate);
3404
+ const floatBits = /* @__PURE__ */ new Float32Array(1);
3405
+ const floatWord = new Uint32Array(floatBits.buffer);
2927
3406
  const edgeTextures = [
2928
3407
  edgeTopology,
2929
3408
  edgeStyle,
@@ -2957,10 +3436,12 @@ function createWebglRenderer(canvas) {
2957
3436
  const nodeProgram = createProgram(gl, nodeVertex, nodeFragment, blocks);
2958
3437
  const edgeProgram = createProgram(gl, edgeVertex, edgeFragment, blocks);
2959
3438
  const labelProgram = createProgram(gl, labelVertex, labelFragment, blocks);
3439
+ const badgeProgram = createProgram(gl, badgeVertex, badgeFragment, blocks);
2960
3440
  const programs = [
2961
3441
  nodeProgram,
2962
3442
  edgeProgram,
2963
- labelProgram
3443
+ labelProgram,
3444
+ badgeProgram
2964
3445
  ];
2965
3446
  const locations = /* @__PURE__ */ new Map();
2966
3447
  function prepare(program) {
@@ -2988,8 +3469,11 @@ function createWebglRenderer(canvas) {
2988
3469
  const curveIndices = createBuffer(gl, /* @__PURE__ */ new Uint32Array(0), gl.DYNAMIC_DRAW);
2989
3470
  const labelMainBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
2990
3471
  const labelHighlightBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
3472
+ const badgeBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
3473
+ const badgeGlyphBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
2991
3474
  const warmIndex = createBuffer(gl, /* @__PURE__ */ new Uint32Array(1), gl.STATIC_DRAW);
2992
3475
  const warmGlyph = createBuffer(gl, /* @__PURE__ */ new Uint8Array(32), gl.STATIC_DRAW);
3476
+ const warmBadge = createBuffer(gl, new Uint8Array(badgeStride), gl.STATIC_DRAW);
2993
3477
  const buffers = [
2994
3478
  nodeCorner,
2995
3479
  edgeCorner,
@@ -3002,8 +3486,11 @@ function createWebglRenderer(canvas) {
3002
3486
  curveIndices,
3003
3487
  labelMainBuffer,
3004
3488
  labelHighlightBuffer,
3489
+ badgeBuffer,
3490
+ badgeGlyphBuffer,
3005
3491
  warmIndex,
3006
- warmGlyph
3492
+ warmGlyph,
3493
+ warmBadge
3007
3494
  ];
3008
3495
  const corner = (buffer, size = 2) => ({
3009
3496
  name: "corner",
@@ -3062,18 +3549,39 @@ function createWebglRenderer(canvas) {
3062
3549
  offset: 24
3063
3550
  }
3064
3551
  ];
3552
+ const badgeField = (buffer, name, offset, type = gl.UNSIGNED_INT) => ({
3553
+ name,
3554
+ buffer,
3555
+ size: 1,
3556
+ type,
3557
+ integer: type === gl.UNSIGNED_INT,
3558
+ divisor: 1,
3559
+ stride: badgeStride,
3560
+ offset
3561
+ });
3562
+ const badgeAttributes = (buffer) => [
3563
+ corner(nodeCorner),
3564
+ badgeField(buffer, "badgeNode", 0),
3565
+ badgeField(buffer, "badgeInfo", 4),
3566
+ badgeField(buffer, "badgeFill", 8),
3567
+ badgeField(buffer, "badgeRingColor", 12),
3568
+ badgeField(buffer, "badgeText", 16, gl.FLOAT)
3569
+ ];
3065
3570
  const vertexArrays = {
3066
3571
  nodes: createVertexArray(gl, nodeProgram, [corner(nodeCorner), instance(nodeIndices)]),
3067
3572
  nodeOverlay: createVertexArray(gl, nodeProgram, [corner(nodeCorner), instance(nodeOverlayIndices)]),
3068
3573
  edges: createVertexArray(gl, edgeProgram, [corner(edgeCorner, 3), instance(edgeIndices)]),
3069
3574
  edgeOverlay: createVertexArray(gl, edgeProgram, [corner(edgeCorner, 3), instance(edgeOverlayIndices)]),
3070
3575
  labels: createVertexArray(gl, labelProgram, glyphs(labelMainBuffer)),
3071
- labelHighlight: createVertexArray(gl, labelProgram, glyphs(labelHighlightBuffer))
3576
+ labelHighlight: createVertexArray(gl, labelProgram, glyphs(labelHighlightBuffer)),
3577
+ badges: createVertexArray(gl, badgeProgram, badgeAttributes(badgeBuffer)),
3578
+ badgeGlyphs: createVertexArray(gl, labelProgram, glyphs(badgeGlyphBuffer))
3072
3579
  };
3073
3580
  const vertexCounts = /* @__PURE__ */ new Map([
3074
3581
  [nodeProgram, cornerNode.length / 2],
3075
3582
  [edgeProgram, cornerEdge.length / 3],
3076
- [labelProgram, cornerLabel.length / 2]
3583
+ [labelProgram, cornerLabel.length / 2],
3584
+ [badgeProgram, cornerNode.length / 2]
3077
3585
  ]);
3078
3586
  let warmed = false;
3079
3587
  let curvePass = null;
@@ -3107,6 +3615,13 @@ function createWebglRenderer(canvas) {
3107
3615
  let orderStale = true;
3108
3616
  let lastLabelTime = Number.NEGATIVE_INFINITY;
3109
3617
  let lastLabelView = "";
3618
+ const imageOf = [];
3619
+ const badged = /* @__PURE__ */ new Set();
3620
+ const badgeGlyphs = new GlyphBuffer();
3621
+ let badgeData = /* @__PURE__ */ new ArrayBuffer(1280);
3622
+ let badgeCount = 0;
3623
+ let badgesStale = false;
3624
+ let badgeGeneration = -1;
3110
3625
  function packNode(nodes, index) {
3111
3626
  const at = index * 4;
3112
3627
  const flags = (nodes.shape[index] ?? 0) | (nodes.screenUnits[index] ?? 0) << 2 | (nodes.alive[index] ?? 0) << 3;
@@ -3117,6 +3632,76 @@ function createWebglRenderer(canvas) {
3117
3632
  nodePaint.data[at] = nodes.fill[index] ?? 0;
3118
3633
  nodePaint.data[at + 1] = nodes.stroke[index] ?? 0;
3119
3634
  nodePaint.data[at + 2] = nodes.slot[index] ?? 0;
3635
+ nodePaint.data[at + 3] = nodes.outline[index] ?? 4294967295;
3636
+ const src = nodes.alive[index] === 1 ? nodes.image[index] ?? null : null;
3637
+ const previous = imageOf[index] ?? null;
3638
+ if (src !== previous) {
3639
+ if (previous) images.release(previous);
3640
+ if (src) images.acquire(src);
3641
+ imageOf[index] = src;
3642
+ }
3643
+ floatBits[0] = nodes.imageScale[index] ?? 1;
3644
+ nodeDecor.data[at] = src ? images.layer(src) + 1 : 0;
3645
+ nodeDecor.data[at + 1] = nodes.imageColor[index] ?? 4294967295;
3646
+ nodeDecor.data[at + 2] = floatWord[0] ?? 0;
3647
+ floatBits[0] = nodes.outlineWidth[index] ?? 0;
3648
+ nodeDecor.data[at + 3] = floatWord[0] ?? 0;
3649
+ const badges = nodes.alive[index] === 1 ? nodes.badges[index] : null;
3650
+ if (badges || badged.has(index)) badgesStale = true;
3651
+ if (badges) badged.add(index);
3652
+ else badged.delete(index);
3653
+ }
3654
+ function refreshImages(nodes) {
3655
+ const loaded = images.takeLoaded();
3656
+ if (!loaded) return;
3657
+ let first = Number.POSITIVE_INFINITY;
3658
+ let last = -1;
3659
+ for (let index = 0; index < nodes.capacity; index++) {
3660
+ const src = imageOf[index];
3661
+ if (!src || !loaded.has(src)) continue;
3662
+ nodeDecor.data[index * 4] = images.layer(src) + 1;
3663
+ first = Math.min(first, index);
3664
+ last = Math.max(last, index);
3665
+ }
3666
+ nodeDecor.upload(first, last);
3667
+ }
3668
+ function rebuildBadges() {
3669
+ if (!input) return;
3670
+ const nodes = input.scene.nodes;
3671
+ const generation = atlas.generation;
3672
+ badgeGlyphs.clear();
3673
+ badgeCount = 0;
3674
+ let words = new Uint32Array(badgeData);
3675
+ let floats = new Float32Array(badgeData);
3676
+ for (const index of badged) for (const badge of nodes.badges[index] ?? []) {
3677
+ if ((badgeCount + 1) * badgeStride > badgeData.byteLength) {
3678
+ const grown = /* @__PURE__ */ new ArrayBuffer(badgeData.byteLength * 2);
3679
+ new Uint8Array(grown).set(new Uint8Array(badgeData));
3680
+ badgeData = grown;
3681
+ words = new Uint32Array(badgeData);
3682
+ floats = new Float32Array(badgeData);
3683
+ }
3684
+ const line = badge.text ? atlas.line(badge.text, "500") : null;
3685
+ const at = badgeCount * (badgeStride / 4);
3686
+ words[at] = index;
3687
+ words[at + 1] = badge.corner | (line ? 4 : 0);
3688
+ words[at + 2] = badge.fill;
3689
+ words[at + 3] = badge.ring;
3690
+ floats[at + 4] = line?.width ?? 0;
3691
+ if (line) badgeGlyphs.push(index, 2 | badge.corner << 2 | badge.color << 4, line, atlas.height);
3692
+ badgeCount++;
3693
+ }
3694
+ if (atlas.generation !== generation) {
3695
+ labelsStale = true;
3696
+ rebuildBadges();
3697
+ return;
3698
+ }
3699
+ badgesStale = false;
3700
+ badgeGeneration = atlas.generation;
3701
+ gl.bindBuffer(gl.ARRAY_BUFFER, badgeBuffer);
3702
+ gl.bufferData(gl.ARRAY_BUFFER, new Uint8Array(badgeData, 0, badgeCount * badgeStride), gl.DYNAMIC_DRAW);
3703
+ gl.bindBuffer(gl.ARRAY_BUFFER, badgeGlyphBuffer);
3704
+ gl.bufferData(gl.ARRAY_BUFFER, new Uint8Array(badgeGlyphs.data, 0, badgeGlyphs.count * 32), gl.DYNAMIC_DRAW);
3120
3705
  }
3121
3706
  function packEdge(edges, index) {
3122
3707
  const at = index * 4;
@@ -3156,6 +3741,7 @@ function createWebglRenderer(canvas) {
3156
3741
  }
3157
3742
  nodeStyle.upload(first, last);
3158
3743
  nodePaint.upload(first, last);
3744
+ nodeDecor.upload(first, last);
3159
3745
  if (next.positionsChanged || next.full || grown) {
3160
3746
  const xy = next.positions.xy;
3161
3747
  nodePosition.data.set(xy.subarray(0, Math.min(xy.length, nodePosition.data.length)));
@@ -3298,7 +3884,8 @@ function createWebglRenderer(canvas) {
3298
3884
  const passes = [
3299
3885
  [nodeProgram, [corner(nodeCorner), instance(warmIndex)]],
3300
3886
  [edgeProgram, [corner(edgeCorner, 3), instance(warmIndex)]],
3301
- [labelProgram, glyphs(warmGlyph)]
3887
+ [labelProgram, glyphs(warmGlyph)],
3888
+ [badgeProgram, badgeAttributes(warmBadge)]
3302
3889
  ];
3303
3890
  gl.enable(gl.SCISSOR_TEST);
3304
3891
  gl.scissor(0, 0, 0, 0);
@@ -3315,6 +3902,7 @@ function createWebglRenderer(canvas) {
3315
3902
  sync(next) {
3316
3903
  input = next;
3317
3904
  syncNodes(next);
3905
+ refreshImages(next.scene.nodes);
3318
3906
  syncEdges(next);
3319
3907
  syncStates(next);
3320
3908
  if (next.dirty.nodes.size > 0 || next.full) labelsStale = true;
@@ -3351,7 +3939,12 @@ function createWebglRenderer(canvas) {
3351
3939
  frameBlock[7] = 8;
3352
3940
  gl.bindBuffer(gl.UNIFORM_BUFFER, frameBuffer);
3353
3941
  gl.bufferSubData(gl.UNIFORM_BUFFER, 0, frameBlock);
3942
+ if (badgesStale || badgeGeneration !== atlas.generation) rebuildBadges();
3943
+ const generation = atlas.generation;
3354
3944
  const wait = selectLabels(frame, performance.now());
3945
+ if (atlas.generation !== generation) rebuildBadges();
3946
+ syncAtlas();
3947
+ images.prepare();
3355
3948
  gl.viewport(0, 0, width, height);
3356
3949
  gl.clearColor(0, 0, 0, 0);
3357
3950
  gl.clear(gl.COLOR_BUFFER_BIT);
@@ -3366,10 +3959,16 @@ function createWebglRenderer(canvas) {
3366
3959
  draw(pass.program, pass.main, curved.size, layer.slot, 0);
3367
3960
  }
3368
3961
  } else draw(nodeProgram, vertexArrays.nodes, nodeCapacity, layer.slot, 0);
3962
+ draw(badgeProgram, vertexArrays.badges, badgeCount, 0, 0);
3963
+ draw(labelProgram, vertexArrays.badgeGlyphs, badgeGlyphs.count, 0, 0);
3369
3964
  draw(labelProgram, vertexArrays.labels, labels.main.count, 0, 0);
3370
3965
  draw(edgeProgram, vertexArrays.edgeOverlay, edgeOverlayCount, 0, 1);
3371
3966
  if (curvePass && curved.size > 0) draw(curvePass.program, curvePass.overlay, edgeOverlayCount, 0, 1);
3372
3967
  draw(nodeProgram, vertexArrays.nodeOverlay, nodeOverlayCount, 0, 1);
3968
+ if (nodeOverlayCount > 0) {
3969
+ draw(badgeProgram, vertexArrays.badges, badgeCount, 0, 1);
3970
+ draw(labelProgram, vertexArrays.badgeGlyphs, badgeGlyphs.count, 0, 1);
3971
+ }
3373
3972
  draw(labelProgram, vertexArrays.labelHighlight, labels.highlight.count, 0, 1);
3374
3973
  gl.bindVertexArray(null);
3375
3974
  return wait;
@@ -3378,6 +3977,7 @@ function createWebglRenderer(canvas) {
3378
3977
  for (const texture of [...nodeTextures, ...edgeTextures]) texture.destroy();
3379
3978
  gl.deleteTexture(palette);
3380
3979
  gl.deleteTexture(atlasTexture);
3980
+ images.destroy();
3381
3981
  for (const buffer of [
3382
3982
  ...buffers,
3383
3983
  frameBuffer,
@@ -3402,12 +4002,15 @@ function createWebglRenderer(canvas) {
3402
4002
  [units.edgeTopology, edgeTopology.texture],
3403
4003
  [units.edgeStyle, edgeStyle.texture],
3404
4004
  [units.edgeState, edgeState.texture],
3405
- [units.atlas, atlasTexture]
4005
+ [units.atlas, atlasTexture],
4006
+ [units.nodeDecor, nodeDecor.texture]
3406
4007
  ];
3407
4008
  for (const [unit, texture] of textures) {
3408
4009
  gl.activeTexture(gl.TEXTURE0 + unit);
3409
4010
  gl.bindTexture(gl.TEXTURE_2D, texture);
3410
4011
  }
4012
+ gl.activeTexture(gl.TEXTURE0 + units.images);
4013
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, images.texture);
3411
4014
  }
3412
4015
  }
3413
4016
  // src/host/events.ts
@@ -3437,8 +4040,71 @@ var Emitter = class {
3437
4040
  this.#handlers.clear();
3438
4041
  }
3439
4042
  };
4043
+ // src/host/keyboard.ts
4044
+ const arrows = {
4045
+ ArrowLeft: {
4046
+ x: -1,
4047
+ y: 0
4048
+ },
4049
+ ArrowRight: {
4050
+ x: 1,
4051
+ y: 0
4052
+ },
4053
+ ArrowUp: {
4054
+ x: 0,
4055
+ y: -1
4056
+ },
4057
+ ArrowDown: {
4058
+ x: 0,
4059
+ y: 1
4060
+ }
4061
+ };
4062
+ const keyZoom = 1.5;
4063
+ function keyAction(event) {
4064
+ if (event.altKey) return null;
4065
+ const command = event.ctrlKey || event.metaKey;
4066
+ const direction = arrows[event.key];
4067
+ if (direction) {
4068
+ if (command) return null;
4069
+ return event.shiftKey ? {
4070
+ type: "pan",
4071
+ direction
4072
+ } : {
4073
+ type: "move",
4074
+ direction
4075
+ };
4076
+ }
4077
+ switch (event.key) {
4078
+ case "Enter":
4079
+ case " ": return {
4080
+ type: "activate",
4081
+ additive: event.shiftKey || command
4082
+ };
4083
+ case "Escape": return { type: "escape" };
4084
+ }
4085
+ if (command) return null;
4086
+ switch (event.key) {
4087
+ case "+":
4088
+ case "=": return {
4089
+ type: "zoom",
4090
+ factor: keyZoom
4091
+ };
4092
+ case "-":
4093
+ case "_": return {
4094
+ type: "zoom",
4095
+ factor: 1 / keyZoom
4096
+ };
4097
+ case "0": return { type: "fit" };
4098
+ }
4099
+ return null;
4100
+ }
3440
4101
  // src/host/state-registry.ts
3441
- const highlightBits = 3;
4102
+ const highlightBits = 7;
4103
+ const highlightStates = [
4104
+ "selected",
4105
+ "hovered",
4106
+ "focused"
4107
+ ];
3442
4108
  const kinds = ["nodes", "edges"];
3443
4109
  function members() {
3444
4110
  return {
@@ -3510,6 +4176,7 @@ var StateRegistry = class {
3510
4176
  apply(changes) {
3511
4177
  let selection = false;
3512
4178
  let hovered = false;
4179
+ let focused = false;
3513
4180
  const forget = (kind, removed) => {
3514
4181
  for (const { key, index } of removed) {
3515
4182
  this.#setMask(kind, index, 0);
@@ -3517,6 +4184,7 @@ var StateRegistry = class {
3517
4184
  if (!this.#members.get(name)?.[kind].delete(key)) continue;
3518
4185
  if (name === "selected") selection = true;
3519
4186
  if (name === "hovered") hovered = true;
4187
+ if (name === "focused") focused = true;
3520
4188
  }
3521
4189
  }
3522
4190
  };
@@ -3529,7 +4197,8 @@ var StateRegistry = class {
3529
4197
  learn("edges", changes.edges.added);
3530
4198
  return {
3531
4199
  selection,
3532
- hovered
4200
+ hovered,
4201
+ focused
3533
4202
  };
3534
4203
  }
3535
4204
  takeHighlighted() {
@@ -3537,7 +4206,7 @@ var StateRegistry = class {
3537
4206
  this.#highlightDirty = false;
3538
4207
  const indices = (kind) => {
3539
4208
  const found = /* @__PURE__ */ new Set();
3540
- for (const name of ["selected", "hovered"]) for (const key of this.#members.get(name)?.[kind] ?? []) {
4209
+ for (const name of highlightStates) for (const key of this.#members.get(name)?.[kind] ?? []) {
3541
4210
  const index = this.#index(kind, key);
3542
4211
  if (index >= 0) found.add(index);
3543
4212
  }
@@ -3582,6 +4251,14 @@ var StateRegistry = class {
3582
4251
  }
3583
4252
  };
3584
4253
  // src/host/mount-graph.ts
4254
+ const svgNamespace = "http://www.w3.org/2000/svg";
4255
+ const lassoStep = 3;
4256
+ const keyPan = .1;
4257
+ const followMargin = 48;
4258
+ const keyDuration = 200;
4259
+ const visuallyHidden = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
4260
+ const focusOutline = "2px solid var(--graph-focus-ring,#c93700)";
4261
+ const lassoStyle = "fill:color-mix(in srgb,var(--graph-accent,#c93700) 10%,transparent);stroke:var(--graph-accent,#c93700);stroke-width:1.5;stroke-dasharray:4 3;stroke-linejoin:round";
3585
4262
  const defaultPadding = 32;
3586
4263
  const clickSlop = 4;
3587
4264
  const pickTolerance = 4;
@@ -3624,7 +4301,9 @@ function resolveStyle(style, id) {
3624
4301
  ...style.halo !== void 0 ? { halo: style.halo } : {},
3625
4302
  ...style.label !== void 0 ? { label: style.label } : {},
3626
4303
  ...style.fill !== void 0 ? { fill: id(style.fill) } : {},
3627
- ...style.stroke !== void 0 ? { stroke: id(style.stroke) } : {}
4304
+ ...style.stroke !== void 0 ? { stroke: id(style.stroke) } : {},
4305
+ ...style.outline !== void 0 ? { outline: id(style.outline) } : {},
4306
+ ...style.outlineWidth !== void 0 || style.outline !== void 0 ? { outlineWidth: style.outlineWidth ?? defaults.nodeOutlineWidth } : {}
3628
4307
  };
3629
4308
  }
3630
4309
  function createGraphHost(container, options, createRenderer) {
@@ -3636,11 +4315,15 @@ function createGraphHost(container, options, createRenderer) {
3636
4315
  element.setAttribute("data-graph", "");
3637
4316
  element.setAttribute("role", "img");
3638
4317
  element.setAttribute("aria-label", options.ariaLabel);
3639
- element.style.cssText = "position:relative;width:100%;height:100%;overflow:hidden";
4318
+ element.style.cssText = "position:relative;width:100%;height:100%;overflow:hidden;outline:none;outline-offset:-2px";
3640
4319
  const canvas = document.createElement("canvas");
3641
4320
  canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;outline:none";
3642
- element.append(canvas);
4321
+ const announcer = document.createElement("div");
4322
+ announcer.setAttribute("aria-live", "polite");
4323
+ announcer.style.cssText = visuallyHidden;
4324
+ element.append(canvas, announcer);
3643
4325
  container.append(element);
4326
+ let remembered = null;
3644
4327
  let frameRequest = 0;
3645
4328
  let laterRequest;
3646
4329
  let needsFlush = false;
@@ -3668,7 +4351,7 @@ function createGraphHost(container, options, createRenderer) {
3668
4351
  const emitter = new Emitter();
3669
4352
  const pickIndex = new PickIndex();
3670
4353
  const themeResolver = new ThemeResolver(element);
3671
- let renderer = createRenderer(canvas);
4354
+ let renderer = createRenderer(canvas, () => request());
3672
4355
  registry.define(freeStateNames(definition.states));
3673
4356
  if (options.selection !== void 0) registry.set("selected", options.selection);
3674
4357
  const store = createStore({
@@ -3680,6 +4363,7 @@ function createGraphHost(container, options, createRenderer) {
3680
4363
  width: 0,
3681
4364
  height: 0,
3682
4365
  hovered: null,
4366
+ focused: null,
3683
4367
  selection: controlled ? registry.members("selected") : noKeys,
3684
4368
  states: freeStates(),
3685
4369
  positionsVersion: 0
@@ -3872,16 +4556,197 @@ function createGraphHost(container, options, createRenderer) {
3872
4556
  updateCursor();
3873
4557
  request();
3874
4558
  }
4559
+ function nodePoint(key) {
4560
+ const record = model.node(key);
4561
+ return record ? {
4562
+ x: positions.x(record.index),
4563
+ y: positions.y(record.index)
4564
+ } : null;
4565
+ }
4566
+ function focusPoint(focus) {
4567
+ if (focus.kind === "node") return nodePoint(focus.key);
4568
+ const ends = model.endpoints(focus.key);
4569
+ const a = ends ? nodePoint(ends.source) : null;
4570
+ const b = ends ? nodePoint(ends.target) : null;
4571
+ return a && b ? {
4572
+ x: (a.x + b.x) / 2,
4573
+ y: (a.y + b.y) / 2
4574
+ } : null;
4575
+ }
4576
+ function labelOf(focus) {
4577
+ const record = focus.kind === "node" ? model.node(focus.key) : model.edge(focus.key);
4578
+ const labels = focus.kind === "node" ? scene.nodes.label : scene.edges.label;
4579
+ return record && labels[record.index] || String(focus.key);
4580
+ }
4581
+ function announce(text) {
4582
+ announcer.textContent = announcer.textContent === text ? `${text}\u00a0` : text;
4583
+ }
4584
+ function reveal(focus) {
4585
+ const point = focusPoint(focus);
4586
+ if (!point) return;
4587
+ const { view } = store.get();
4588
+ const size = viewport();
4589
+ const screen = toScreen(view, size, point);
4590
+ if (!(screen.x >= followMargin && screen.x <= size.width - followMargin && screen.y >= followMargin && screen.y <= size.height - followMargin)) animateTo({
4591
+ ...view,
4592
+ x: point.x,
4593
+ y: point.y
4594
+ }, keyDuration);
4595
+ }
4596
+ function setFocused(next, follow = false) {
4597
+ if (sameTarget(store.get().focused, next)) return;
4598
+ registry.set("focused", next ? { [kindOf[next.kind]]: [next.key] } : {});
4599
+ patch({ focused: next });
4600
+ if (next) {
4601
+ element.style.outline = "none";
4602
+ announce(labelOf(next));
4603
+ if (follow) reveal(next);
4604
+ }
4605
+ emitter.emit("focuschange", { target: next });
4606
+ request();
4607
+ }
4608
+ function moveFocus(direction) {
4609
+ const current = store.get().focused;
4610
+ const from = current?.kind === "node" ? model.node(current.key)?.index ?? -1 : -1;
4611
+ let index = from >= 0 ? nodeToward(from, direction, scene.nodes, positions) : -1;
4612
+ if (from < 0) {
4613
+ const selected = registry.members("selected").nodes;
4614
+ const last = selected[selected.length - 1];
4615
+ const center = store.get().view;
4616
+ index = last !== void 0 ? model.node(last)?.index ?? -1 : nearestNode((current && focusPoint(current)) ?? center, scene.nodes, positions);
4617
+ }
4618
+ const key = index >= 0 ? scene.nodes.keys[index] : void 0;
4619
+ const next = key !== void 0 ? target("node", key) : null;
4620
+ if (next) setFocused(next, true);
4621
+ }
4622
+ function focusVisible() {
4623
+ try {
4624
+ return element.matches(":focus-visible");
4625
+ } catch {
4626
+ return true;
4627
+ }
4628
+ }
4629
+ function onFocus() {
4630
+ if (!definition.interaction.keyboard || !focusVisible()) return;
4631
+ const restored = remembered ? target(remembered.kind, remembered.key) : null;
4632
+ remembered = null;
4633
+ if (restored) setFocused(restored, true);
4634
+ else if (!store.get().focused) element.style.outline = focusOutline;
4635
+ }
4636
+ function onBlur() {
4637
+ element.style.outline = "none";
4638
+ const current = store.get().focused;
4639
+ if (!current) return;
4640
+ remembered = current;
4641
+ setFocused(null);
4642
+ }
4643
+ function cancelLasso() {
4644
+ if (!gesture?.lasso) return false;
4645
+ if (canvas.hasPointerCapture?.(gesture.id)) canvas.releasePointerCapture(gesture.id);
4646
+ gesture = null;
4647
+ drawLasso(null);
4648
+ updateCursor();
4649
+ return true;
4650
+ }
4651
+ function runKey(event) {
4652
+ const action = keyAction(event);
4653
+ if (!action) return false;
4654
+ const { interaction } = definition;
4655
+ const { view, focused, selection } = store.get();
4656
+ const size = viewport();
4657
+ switch (action.type) {
4658
+ case "move":
4659
+ moveFocus(action.direction);
4660
+ return true;
4661
+ case "pan": {
4662
+ if (!interaction.pan) return false;
4663
+ const step = Math.min(size.width, size.height) * keyPan;
4664
+ commitView(panBy(view, -action.direction.x * step, -action.direction.y * step));
4665
+ return true;
4666
+ }
4667
+ case "zoom": {
4668
+ if (!interaction.zoom) return false;
4669
+ const point = focused ? focusPoint(focused) : null;
4670
+ animateTo(zoomAround(view, size, point ? toScreen(view, size, point) : {
4671
+ x: size.width / 2,
4672
+ y: size.height / 2
4673
+ }, action.factor), keyDuration);
4674
+ return true;
4675
+ }
4676
+ case "fit": return fitTo(void 0, { duration: keyDuration });
4677
+ case "activate":
4678
+ if (!focused || interaction.select === false) return false;
4679
+ selectTarget(focused, action.additive);
4680
+ return true;
4681
+ case "escape":
4682
+ if (cancelLasso()) return true;
4683
+ if (interaction.select !== false && selection.nodes.length + selection.edges.length > 0) {
4684
+ requestSelection(noKeys);
4685
+ return true;
4686
+ }
4687
+ if (!focused) return false;
4688
+ setFocused(null);
4689
+ return true;
4690
+ }
4691
+ }
4692
+ function onKeyDown(event) {
4693
+ if (!definition.interaction.keyboard || event.target !== element) return;
4694
+ if (runKey(event)) event.preventDefault();
4695
+ }
4696
+ function applyKeyboard() {
4697
+ if (definition.interaction.keyboard) {
4698
+ element.tabIndex = 0;
4699
+ element.setAttribute("role", "application");
4700
+ return;
4701
+ }
4702
+ element.removeAttribute("tabindex");
4703
+ element.setAttribute("role", "img");
4704
+ element.style.outline = "none";
4705
+ setFocused(null);
4706
+ }
4707
+ let lassoLayer = null;
4708
+ function drawLasso(points) {
4709
+ if (!points) {
4710
+ lassoLayer?.remove();
4711
+ lassoLayer = null;
4712
+ return;
4713
+ }
4714
+ if (!lassoLayer) {
4715
+ lassoLayer = document.createElementNS(svgNamespace, "svg");
4716
+ lassoLayer.setAttribute("aria-hidden", "true");
4717
+ lassoLayer.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible";
4718
+ const path = document.createElementNS(svgNamespace, "path");
4719
+ path.style.cssText = lassoStyle;
4720
+ lassoLayer.append(path);
4721
+ element.append(lassoLayer);
4722
+ }
4723
+ lassoLayer.firstElementChild?.setAttribute("d", `M${points.map((point) => `${point.x} ${point.y}`).join("L")}Z`);
4724
+ }
4725
+ function selectInside(points) {
4726
+ const view = store.get().view;
4727
+ const size = viewport();
4728
+ const inside = nodesInside(points.map((point) => toWorld(view, size, point)), scene.nodes, positions).filter((key) => !registry.has("selected", "nodes", key));
4729
+ if (inside.length === 0) return;
4730
+ const current = registry.members("selected");
4731
+ requestSelection({
4732
+ ...current,
4733
+ nodes: [...current.nodes, ...inside]
4734
+ });
4735
+ }
3875
4736
  function updateCursor() {
3876
4737
  const interactive = definition.interaction.select !== false || definition.interaction.drag;
3877
- if (gesture?.moved) canvas.style.cursor = "grabbing";
4738
+ if (gesture?.lasso) canvas.style.cursor = "crosshair";
4739
+ else if (gesture?.moved) canvas.style.cursor = "grabbing";
3878
4740
  else if (store.get().hovered && interactive) canvas.style.cursor = "pointer";
3879
4741
  else canvas.style.cursor = "";
3880
4742
  }
3881
4743
  function selectOnClick(clicked, event) {
4744
+ selectTarget(clicked, event.shiftKey || event.metaKey || event.ctrlKey);
4745
+ }
4746
+ function selectTarget(clicked, modifier) {
3882
4747
  const mode = definition.interaction.select;
3883
4748
  if (mode === false) return;
3884
- const additive = mode === "multiple" && (event.shiftKey || event.metaKey || event.ctrlKey);
4749
+ const additive = mode === "multiple" && modifier;
3885
4750
  if (!clicked) {
3886
4751
  if (!additive) requestSelection(noKeys);
3887
4752
  return;
@@ -3920,6 +4785,14 @@ function createGraphHost(container, options, createRenderer) {
3920
4785
  const fresh = target(hovered.kind, hovered.key);
3921
4786
  if (fresh && fresh.datum !== hovered.datum) patch({ hovered: fresh });
3922
4787
  }
4788
+ const focused = store.get().focused;
4789
+ if (result.focused) {
4790
+ patch({ focused: null });
4791
+ emitter.emit("focuschange", { target: null });
4792
+ } else if (focused) {
4793
+ const fresh = target(focused.kind, focused.key);
4794
+ if (fresh && fresh.datum !== focused.datum) patch({ focused: fresh });
4795
+ }
3923
4796
  }
3924
4797
  function movePositions(indices, to, duration) {
3925
4798
  if (indices.length === 0) return;
@@ -4101,18 +4974,24 @@ function createGraphHost(container, options, createRenderer) {
4101
4974
  function onPointerDown(event) {
4102
4975
  if (event.button !== 0 || gesture) return;
4103
4976
  viewAnimation = null;
4977
+ remembered = null;
4978
+ setFocused(null);
4104
4979
  const point = local(event);
4105
4980
  const hit = pickAt(point);
4106
4981
  const drag = hit ? startDrag(hit, point) : null;
4982
+ const { lasso, select } = definition.interaction;
4983
+ const lassoing = !hit && event.shiftKey && lasso && select === "multiple";
4107
4984
  gesture = {
4108
4985
  id: event.pointerId,
4109
4986
  start: point,
4110
4987
  last: point,
4111
4988
  target: hit,
4112
4989
  drag,
4990
+ lasso: lassoing ? [point] : null,
4113
4991
  moved: false
4114
4992
  };
4115
4993
  canvas.setPointerCapture?.(event.pointerId);
4994
+ if (lassoing) updateCursor();
4116
4995
  }
4117
4996
  function onPointerMove(event) {
4118
4997
  const point = local(event);
@@ -4125,6 +5004,16 @@ function createGraphHost(container, options, createRenderer) {
4125
5004
  gesture.moved = true;
4126
5005
  updateCursor();
4127
5006
  }
5007
+ const lasso = gesture.lasso;
5008
+ if (lasso) {
5009
+ const last = lasso[lasso.length - 1] ?? gesture.start;
5010
+ if (gesture.moved && Math.hypot(point.x - last.x, point.y - last.y) >= lassoStep) {
5011
+ lasso.push(point);
5012
+ drawLasso(lasso);
5013
+ }
5014
+ gesture.last = point;
5015
+ return;
5016
+ }
4128
5017
  const drag = gesture.drag;
4129
5018
  if (drag) {
4130
5019
  if (!gesture.moved) return;
@@ -4161,6 +5050,10 @@ function createGraphHost(container, options, createRenderer) {
4161
5050
  gesture = null;
4162
5051
  if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
4163
5052
  if (ended.drag?.started) emitter.emit("dragend", dragEvent(ended.drag, event));
5053
+ if (ended.lasso) {
5054
+ drawLasso(null);
5055
+ if (!cancelled && ended.moved) selectInside(ended.lasso);
5056
+ }
4164
5057
  if (!cancelled && !ended.moved) {
4165
5058
  emitter.emit("click", pointerEvent(event, ended.target));
4166
5059
  selectOnClick(ended.target, event);
@@ -4198,7 +5091,7 @@ function createGraphHost(container, options, createRenderer) {
4198
5091
  }
4199
5092
  function onContextRestored() {
4200
5093
  contextLost = false;
4201
- renderer = createRenderer(canvas);
5094
+ renderer = createRenderer(canvas, () => request());
4202
5095
  full = true;
4203
5096
  refreshTheme();
4204
5097
  }
@@ -4219,6 +5112,13 @@ function createGraphHost(container, options, createRenderer) {
4219
5112
  ["webglcontextrestored", onContextRestored]
4220
5113
  ];
4221
5114
  for (const [name, listener, listenerOptions] of listeners) canvas.addEventListener(name, listener, listenerOptions);
5115
+ const elementListeners = [
5116
+ ["keydown", onKeyDown],
5117
+ ["focus", onFocus],
5118
+ ["blur", onBlur]
5119
+ ];
5120
+ for (const [name, listener] of elementListeners) element.addEventListener(name, listener);
5121
+ applyKeyboard();
4222
5122
  model.setMarks(definition.marks);
4223
5123
  scene.setDefinition(definition);
4224
5124
  applyLayers();
@@ -4253,6 +5153,15 @@ function createGraphHost(container, options, createRenderer) {
4253
5153
  set: (keys) => requestSelection(toKeys(keys)),
4254
5154
  clear: () => requestSelection(noKeys)
4255
5155
  },
5156
+ focus: {
5157
+ get: () => store.get().focused,
5158
+ set: (next) => {
5159
+ const resolved = next ? target(next.kind, next.key) : null;
5160
+ if (next && !resolved) return;
5161
+ if (resolved && definition.interaction.keyboard) element.focus({ preventScroll: true });
5162
+ setFocused(resolved, true);
5163
+ }
5164
+ },
4256
5165
  states: {
4257
5166
  set: (name, keys) => {
4258
5167
  if (registry.set(declared(name), keys)) publishState(name);
@@ -4315,6 +5224,7 @@ function createGraphHost(container, options, createRenderer) {
4315
5224
  applyLayers();
4316
5225
  if (registry.define(freeStateNames(definition.states))) patch({ states: freeStates() });
4317
5226
  applyStyles();
5227
+ applyKeyboard();
4318
5228
  updateCursor();
4319
5229
  request(true);
4320
5230
  }
@@ -4327,6 +5237,7 @@ function createGraphHost(container, options, createRenderer) {
4327
5237
  resizeObserver.disconnect();
4328
5238
  stopTheme();
4329
5239
  for (const [name, listener] of listeners) canvas.removeEventListener(name, listener);
5240
+ for (const [name, listener] of elementListeners) element.removeEventListener(name, listener);
4330
5241
  emitter.clear();
4331
5242
  model.destroy();
4332
5243
  renderer.destroy();
@@ -4340,4 +5251,4 @@ function mountGraph(container, options) {
4340
5251
  }
4341
5252
  export { freeStateNames as n, maxFreeStates as r, mountGraph as t };
4342
5253
 
4343
- //# sourceMappingURL=mount-graph-DYOgD_ql.js.map
5254
+ //# sourceMappingURL=mount-graph-mObhwJ3e.js.map