@bruno00o/graph 0.2.0 → 0.3.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;
@@ -994,7 +1027,10 @@ const defaults = {
994
1027
  nodeStrokeWidth: 1,
995
1028
  edgeWidth: 1.5,
996
1029
  edgeWidthRange: [1, 6],
997
- edgeColor: themeColor("edge")
1030
+ edgeColor: themeColor("edge"),
1031
+ badgeFill: themeColor("badge-fill"),
1032
+ badgeInk: themeColor("badge-ink"),
1033
+ badgeRing: themeColor("node-ring")
998
1034
  };
999
1035
  const builtInStyles = {
1000
1036
  nodes: {
@@ -1148,6 +1184,13 @@ function resolveNumberScale(options, fallback) {
1148
1184
  };
1149
1185
  }
1150
1186
  // src/core/columns.ts
1187
+ const noColor = 4294967295;
1188
+ const cornerCodes = {
1189
+ topLeft: 0,
1190
+ topRight: 1,
1191
+ bottomLeft: 2,
1192
+ bottomRight: 3
1193
+ };
1151
1194
  const shapeCodes = {
1152
1195
  circle: 0,
1153
1196
  square: 1,
@@ -1187,6 +1230,8 @@ var NodeColumns = class {
1187
1230
  label = [];
1188
1231
  secondary = [];
1189
1232
  image = [];
1233
+ imageScale = /* @__PURE__ */ new Float32Array(0);
1234
+ imageColor = /* @__PURE__ */ new Uint32Array(0);
1190
1235
  badges = [];
1191
1236
  reserve(index) {
1192
1237
  if (index < this.capacity) return;
@@ -1200,6 +1245,8 @@ var NodeColumns = class {
1200
1245
  this.strokeWidth = grow$1(this.strokeWidth, capacity);
1201
1246
  this.opacity = grow$1(this.opacity, capacity);
1202
1247
  this.screenUnits = grow$1(this.screenUnits, capacity);
1248
+ this.imageScale = grow$1(this.imageScale, capacity);
1249
+ this.imageColor = grow$1(this.imageColor, capacity);
1203
1250
  this.capacity = capacity;
1204
1251
  }
1205
1252
  clear(index) {
@@ -1349,8 +1396,8 @@ function createEdgeLanes() {
1349
1396
  }
1350
1397
  function link(index, source, target) {
1351
1398
  reserve(index);
1352
- const low = source < target ? source : target;
1353
- const high = source < target ? target : source;
1399
+ const low = Math.min(source, target);
1400
+ const high = Math.max(source, target);
1354
1401
  const current = slots[index] ?? -1;
1355
1402
  if (current >= 0) {
1356
1403
  if (lows[current] === low && highs[current] === high) return;
@@ -1468,18 +1515,37 @@ function finiteOr(value, fallback) {
1468
1515
  function codeOf(codes, value) {
1469
1516
  return typeof value === "string" && Object.hasOwn(codes, value) ? codes[value] : 0;
1470
1517
  }
1471
- function readBadges(mark, datum) {
1518
+ function readBadges(mark, datum, palette) {
1472
1519
  if (mark.badges.length === 0) return null;
1473
- const badges = {};
1474
- let count = 0;
1520
+ const badges = [];
1475
1521
  for (const [corner, read] of mark.badges) {
1476
1522
  const badge = read(datum);
1477
- if (typeof badge === "object" && badge !== null) {
1478
- badges[corner] = badge;
1479
- count++;
1480
- }
1523
+ if (typeof badge !== "object" || badge === null) continue;
1524
+ const { text, fill, color } = badge;
1525
+ badges.push({
1526
+ corner: cornerCodes[corner],
1527
+ text: typeof text === "number" ? String(text) : toText(text) || null,
1528
+ fill: palette.id(toColor(fill) ?? defaults.badgeFill),
1529
+ color: palette.id(toColor(color) ?? defaults.badgeInk),
1530
+ ring: palette.id(defaults.badgeRing)
1531
+ });
1481
1532
  }
1482
- return count === 0 ? null : badges;
1533
+ return badges.length === 0 ? null : badges;
1534
+ }
1535
+ function readImage(value) {
1536
+ if (typeof value === "string") return value === "" ? null : {
1537
+ src: value,
1538
+ scale: 1,
1539
+ color: null
1540
+ };
1541
+ if (typeof value !== "object" || value === null) return null;
1542
+ const { src, scale, color } = value;
1543
+ if (typeof src !== "string" || src === "") return null;
1544
+ return {
1545
+ src,
1546
+ scale: Math.max(0, finiteOr(scale, 1)),
1547
+ color: toColor(color)
1548
+ };
1483
1549
  }
1484
1550
  function layersOf(marks) {
1485
1551
  const slots = {
@@ -1569,8 +1635,11 @@ function createScene() {
1569
1635
  nodes.screenUnits[index] = mark.screenUnits ? 1 : 0;
1570
1636
  nodes.label[index] = toText(mark.label?.(datum));
1571
1637
  nodes.secondary[index] = toText(mark.secondary?.(datum));
1572
- nodes.image[index] = toText(mark.image?.(datum));
1573
- nodes.badges[index] = readBadges(mark, datum);
1638
+ const image = readImage(mark.image?.(datum));
1639
+ nodes.image[index] = image?.src ?? null;
1640
+ nodes.imageScale[index] = image?.scale ?? 1;
1641
+ nodes.imageColor[index] = image?.color ? palette.id(image.color) : noColor;
1642
+ nodes.badges[index] = readBadges(mark, datum, palette);
1574
1643
  dirtyNodes.add(index);
1575
1644
  }
1576
1645
  function writeEdge(model, record) {
@@ -1710,6 +1779,47 @@ function createScene() {
1710
1779
  }
1711
1780
  };
1712
1781
  }
1782
+ // src/core/spatial.ts
1783
+ const sideWeight = 2;
1784
+ const cone = 2;
1785
+ function nearestNode(point, nodes, positions) {
1786
+ let best = -1;
1787
+ let bestDistance = Number.POSITIVE_INFINITY;
1788
+ for (let index = 0; index < nodes.capacity; index++) {
1789
+ if (nodes.alive[index] !== 1) continue;
1790
+ const distance = Math.hypot(positions.x(index) - point.x, positions.y(index) - point.y);
1791
+ if (distance < bestDistance) {
1792
+ best = index;
1793
+ bestDistance = distance;
1794
+ }
1795
+ }
1796
+ return best;
1797
+ }
1798
+ function nodeToward(from, direction, nodes, positions) {
1799
+ const x = positions.x(from);
1800
+ const y = positions.y(from);
1801
+ const length = Math.hypot(direction.x, direction.y);
1802
+ if (length === 0) return -1;
1803
+ const dx = direction.x / length;
1804
+ const dy = direction.y / length;
1805
+ let best = -1;
1806
+ let bestScore = Number.POSITIVE_INFINITY;
1807
+ for (let index = 0; index < nodes.capacity; index++) {
1808
+ if (index === from || nodes.alive[index] !== 1) continue;
1809
+ const vx = positions.x(index) - x;
1810
+ const vy = positions.y(index) - y;
1811
+ const along = vx * dx + vy * dy;
1812
+ if (along <= 0) continue;
1813
+ const side = Math.abs(vx * dy - vy * dx);
1814
+ if (side > along * cone) continue;
1815
+ const score = along + side * sideWeight;
1816
+ if (score < bestScore) {
1817
+ best = index;
1818
+ bestScore = score;
1819
+ }
1820
+ }
1821
+ return best;
1822
+ }
1713
1823
  const noPaint = 4294967295;
1714
1824
  const tableOffset = {
1715
1825
  nodes: 0,
@@ -2191,12 +2301,162 @@ var GlyphAtlas = class {
2191
2301
  this.generation++;
2192
2302
  }
2193
2303
  };
2304
+ const levels = Math.log2(256) + 1;
2305
+ const maxLayers = 256;
2306
+ var ImageAtlas = class {
2307
+ #gl;
2308
+ #invalidate;
2309
+ #entries = /* @__PURE__ */ new Map();
2310
+ #free = [];
2311
+ #placeholder;
2312
+ #texture = null;
2313
+ #capacity = 0;
2314
+ #next = 0;
2315
+ #loaded = /* @__PURE__ */ new Set();
2316
+ #mipsStale = false;
2317
+ #canvas = null;
2318
+ #warnedFull = false;
2319
+ #destroyed = false;
2320
+ constructor(gl, invalidate) {
2321
+ this.#gl = gl;
2322
+ this.#invalidate = invalidate;
2323
+ this.#placeholder = gl.createTexture();
2324
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.#placeholder);
2325
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, 1, 1, 1);
2326
+ }
2327
+ get texture() {
2328
+ return this.#texture ?? this.#placeholder;
2329
+ }
2330
+ layer(src) {
2331
+ return this.#entries.get(src)?.layer ?? -1;
2332
+ }
2333
+ acquire(src) {
2334
+ const entry = this.#entries.get(src);
2335
+ if (entry) {
2336
+ entry.count++;
2337
+ return;
2338
+ }
2339
+ this.#entries.set(src, {
2340
+ count: 1,
2341
+ layer: -1
2342
+ });
2343
+ const image = new Image();
2344
+ image.crossOrigin = "anonymous";
2345
+ image.decoding = "async";
2346
+ image.src = src;
2347
+ image.decode().then(() => this.#store(src, image), () => this.#fail(src));
2348
+ }
2349
+ release(src) {
2350
+ const entry = this.#entries.get(src);
2351
+ if (!entry || --entry.count > 0) return;
2352
+ this.#entries.delete(src);
2353
+ if (entry.layer >= 0) this.#free.push(entry.layer);
2354
+ }
2355
+ takeLoaded() {
2356
+ if (this.#loaded.size === 0) return null;
2357
+ const loaded = this.#loaded;
2358
+ this.#loaded = /* @__PURE__ */ new Set();
2359
+ return loaded;
2360
+ }
2361
+ prepare() {
2362
+ if (!this.#mipsStale || !this.#texture) return;
2363
+ this.#mipsStale = false;
2364
+ const gl = this.#gl;
2365
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.#texture);
2366
+ gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
2367
+ }
2368
+ destroy() {
2369
+ this.#destroyed = true;
2370
+ this.#gl.deleteTexture(this.#placeholder);
2371
+ if (this.#texture) this.#gl.deleteTexture(this.#texture);
2372
+ }
2373
+ #fail(src) {
2374
+ if (this.#destroyed) return;
2375
+ if (dev && this.#entries.has(src)) console.warn(`graph: cannot load the image ${src}`);
2376
+ }
2377
+ #store(src, image) {
2378
+ const entry = this.#entries.get(src);
2379
+ if (this.#destroyed || !entry || entry.layer >= 0) return;
2380
+ const layer = this.#free.pop() ?? this.#next;
2381
+ if (layer >= this.#capacity && !this.#grow()) {
2382
+ if (dev && !this.#warnedFull) {
2383
+ this.#warnedFull = true;
2384
+ console.warn(`graph: more than ${maxLayers} distinct images, the others are not drawn`);
2385
+ }
2386
+ return;
2387
+ }
2388
+ if (layer === this.#next) this.#next++;
2389
+ const canvas = this.#draw(image);
2390
+ const gl = this.#gl;
2391
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.#texture);
2392
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
2393
+ try {
2394
+ gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, 256, 256, 1, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
2395
+ } catch {
2396
+ this.#free.push(layer);
2397
+ this.#fail(src);
2398
+ return;
2399
+ } finally {
2400
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
2401
+ }
2402
+ entry.layer = layer;
2403
+ this.#mipsStale = true;
2404
+ this.#loaded.add(src);
2405
+ this.#invalidate();
2406
+ }
2407
+ #draw(image) {
2408
+ this.#canvas ??= Object.assign(document.createElement("canvas"), {
2409
+ width: 256,
2410
+ height: 256
2411
+ });
2412
+ const context = this.#canvas.getContext("2d");
2413
+ if (context) {
2414
+ context.clearRect(0, 0, 256, 256);
2415
+ const width = image.naturalWidth;
2416
+ const height = image.naturalHeight;
2417
+ const scale = width > 0 && height > 0 ? 256 / Math.min(width, height) : 0;
2418
+ const drawnWidth = scale > 0 ? width * scale : 256;
2419
+ const drawnHeight = scale > 0 ? height * scale : 256;
2420
+ context.drawImage(image, (256 - drawnWidth) / 2, (256 - drawnHeight) / 2, drawnWidth, drawnHeight);
2421
+ }
2422
+ return this.#canvas;
2423
+ }
2424
+ #grow() {
2425
+ if (this.#capacity >= maxLayers) return false;
2426
+ const gl = this.#gl;
2427
+ const capacity = Math.min(maxLayers, Math.max(4, this.#capacity * 2));
2428
+ const texture = gl.createTexture();
2429
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, texture);
2430
+ gl.texStorage3D(gl.TEXTURE_2D_ARRAY, levels, gl.RGBA8, 256, 256, capacity);
2431
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
2432
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2433
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2434
+ gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2435
+ const previous = this.#texture;
2436
+ if (previous) {
2437
+ const framebuffer = gl.createFramebuffer();
2438
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, framebuffer);
2439
+ for (let layer = 0; layer < this.#capacity; layer++) {
2440
+ gl.framebufferTextureLayer(gl.READ_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, previous, 0, layer);
2441
+ gl.copyTexSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, 0, 0, 256, 256);
2442
+ }
2443
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
2444
+ gl.deleteFramebuffer(framebuffer);
2445
+ gl.deleteTexture(previous);
2446
+ }
2447
+ this.#texture = texture;
2448
+ this.#capacity = capacity;
2449
+ this.#mipsStale = true;
2450
+ return true;
2451
+ }
2452
+ };
2194
2453
  const primarySize = 12;
2195
2454
  const lineHeight = 1.25;
2196
2455
  const labelMin = 8;
2197
2456
  const cell = 8;
2198
2457
  const maxLabels = 4e3;
2199
2458
  const cullMargin = 400;
2459
+ const forcedStates = 6;
2200
2460
  var GlyphBuffer = class {
2201
2461
  data = /* @__PURE__ */ new ArrayBuffer(8192);
2202
2462
  count = 0;
@@ -2259,7 +2519,9 @@ var LabelLayer = class {
2259
2519
  const highlighted = new Set(frame.highlighted);
2260
2520
  const worldPrimary = primarySize * Math.min(1, view.zoom);
2261
2521
  let placed = 0;
2262
- const place = (index, force) => {
2522
+ const forced = (index) => ((frame.nodeMask[index] ?? 0) & forcedStates) !== 0;
2523
+ const place = (index, highlight) => {
2524
+ const force = highlight && forced(index);
2263
2525
  const label = nodes.label[index];
2264
2526
  if (!label || nodes.alive[index] !== 1) return;
2265
2527
  if (!labelVisible(frame.nodeMask[index] ?? 0, frame.labelFlags)) return;
@@ -2290,12 +2552,13 @@ var LabelLayer = class {
2290
2552
  for (let row = r0; row <= r1; row++) for (let col = c0; col <= c1; col++) if (occupancy[row * cols + col] === 1) return;
2291
2553
  }
2292
2554
  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;
2555
+ const target = highlight ? this.highlight : this.main;
2294
2556
  target.push(index, 0, line, atlas.height);
2295
2557
  if (secondary) target.push(index, 1, secondary, atlas.height);
2296
2558
  placed++;
2297
2559
  };
2298
- for (const index of highlighted) place(index, true);
2560
+ for (const index of highlighted) if (forced(index)) place(index, true);
2561
+ for (const index of highlighted) if (!forced(index)) place(index, true);
2299
2562
  if (worldPrimary < labelMin && !frame.screenLabels) return;
2300
2563
  const xy = positions.xy;
2301
2564
  const order = frame.order;
@@ -2328,12 +2591,14 @@ function sortBySize(nodes, order) {
2328
2591
  screenLabels
2329
2592
  };
2330
2593
  }
2331
- // src/webgl/shaders.ts
2594
+ const badgeFont = .66;
2595
+ const badgeRing = 1.5;
2332
2596
  const header = `#version 300 es
2333
2597
  precision highp float;
2334
2598
  precision highp int;
2335
2599
  precision highp sampler2D;
2336
2600
  precision highp usampler2D;
2601
+ precision highp sampler2DArray;
2337
2602
  layout(std140) uniform Frame {
2338
2603
  vec2 center;
2339
2604
  vec2 viewport;
@@ -2353,7 +2618,7 @@ layout(std140) uniform States {
2353
2618
  uniform int layerSlot;
2354
2619
  uniform int overlay;
2355
2620
  uniform sampler2D palette;
2356
- const uint highlightMask = 3u;
2621
+ const uint highlightMask = 7u;
2357
2622
  const uint none = 0xffffffffu;
2358
2623
  const vec4 hidden = vec4(2.0, 2.0, 2.0, 1.0);
2359
2624
  vec2 toScreen(vec2 world) { return (world - center) * zoom + viewport * 0.5; }
@@ -2398,6 +2663,13 @@ float nodeDiameter(vec4 style) {
2398
2663
  return (flags & 4u) != 0u ? style.x : style.x * zoom;
2399
2664
  }
2400
2665
  float nodeRadius(vec4 style) { return max(nodeDiameter(style) * 0.5, 1.25); }
2666
+ float badgeHeight(vec4 style) { return min(nodeDiameter(style) * 0.55, ${18 .toFixed(1)}); }
2667
+ vec2 badgeCenter(vec2 xy, vec4 style, uint corner) {
2668
+ uint shape = uint(style.w + 0.5) & 3u;
2669
+ float reach = shape == 0u ? 0.70710678 : shape == 1u ? 0.86 : 0.6;
2670
+ vec2 side = vec2((corner & 1u) != 0u ? 1.0 : -1.0, (corner & 2u) != 0u ? 1.0 : -1.0);
2671
+ return toScreen(xy) + side * reach * nodeRadius(style);
2672
+ }
2401
2673
  `;
2402
2674
  const edgeData = `
2403
2675
  uniform usampler2D edgeTopology;
@@ -2405,9 +2677,13 @@ uniform sampler2D edgeStyle;
2405
2677
  uniform usampler2D edgeState;
2406
2678
  `;
2407
2679
  const nodeVertex = `${header}${nodeData}
2680
+ uniform usampler2D nodeImage;
2408
2681
  in vec2 corner;
2409
2682
  in uint instance;
2410
2683
  out vec2 vLocal;
2684
+ flat out float vImageLayer;
2685
+ flat out float vImageScale;
2686
+ flat out vec4 vImageColor;
2411
2687
  out float vRadius;
2412
2688
  out float vStrokeWidth;
2413
2689
  out float vHaloWidth;
@@ -2438,11 +2714,19 @@ void main() {
2438
2714
  vFill = paletteColor(look.fill);
2439
2715
  vStroke = paletteColor(look.stroke);
2440
2716
  vOpacity = look.opacity;
2717
+ uvec4 image = texelFetch(nodeImage, t, 0);
2718
+ vImageLayer = float(image.x) - 1.0;
2719
+ vImageScale = max(uintBitsToFloat(image.z), 0.001);
2720
+ vImageColor = image.y == none ? vec4(-1.0) : paletteColor(image.y);
2441
2721
  gl_Position = toClip(toScreen(xy) + vLocal);
2442
2722
  }
2443
2723
  `;
2444
2724
  const nodeFragment = `${header}
2725
+ uniform sampler2DArray images;
2445
2726
  in vec2 vLocal;
2727
+ flat in float vImageLayer;
2728
+ flat in float vImageScale;
2729
+ flat in vec4 vImageColor;
2446
2730
  in float vRadius;
2447
2731
  in float vStrokeWidth;
2448
2732
  in float vHaloWidth;
@@ -2464,9 +2748,17 @@ void main() {
2464
2748
  float d = shapeDistance(vLocal, vRadius);
2465
2749
  float inside = 1.0 - smoothstep(-aa, aa, d);
2466
2750
  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);
2751
+ vec4 fill = vec4(vFill.rgb * vFill.a, vFill.a);
2752
+ if (vImageLayer >= 0.0) {
2753
+ vec2 uv = vLocal / (vRadius * 2.0 * vImageScale) + 0.5;
2754
+ vec4 picture = texture(images, vec3(uv, vImageLayer));
2755
+ vec2 within = step(vec2(0.0), uv) * step(uv, vec2(1.0));
2756
+ picture *= within.x * within.y;
2757
+ if (vImageColor.a >= 0.0) picture = vec4(vImageColor.rgb * vImageColor.a, vImageColor.a) * picture.a;
2758
+ fill = picture + fill * (1.0 - picture.a);
2759
+ }
2760
+ vec4 color = mix(fill, vec4(vStroke.rgb * vStroke.a, vStroke.a), ring);
2761
+ vec4 result = color * inside * vOpacity;
2470
2762
  if (vHaloWidth > 0.0) {
2471
2763
  float h = haloColor.a * (1.0 - smoothstep(0.0, vHaloWidth, d)) * vOpacity;
2472
2764
  result += vec4(haloColor.rgb * h, h) * (1.0 - result.a);
@@ -2749,7 +3041,8 @@ in vec4 glyphRect;
2749
3041
  in vec4 glyphUv;
2750
3042
  out vec2 vUv;
2751
3043
  out float vScale;
2752
- flat out uint vKind;
3044
+ flat out vec4 vInk;
3045
+ flat out float vHalo;
2753
3046
  flat out float vOpacity;
2754
3047
  void main() {
2755
3048
  ivec2 t = texel(glyphNode);
@@ -2758,19 +3051,36 @@ void main() {
2758
3051
  uint flags = uint(style.w + 0.5);
2759
3052
  uint mask = texelFetch(nodeState, t, 0).r;
2760
3053
  Look look = resolve(mask, Look(style.z, style.y, 0.0, 1.0, paint.x, paint.y), 0);
3054
+ vUv = mix(glyphUv.xy, glyphUv.zw, corner);
3055
+ vOpacity = look.opacity;
3056
+ uint kind = glyphKind & 3u;
3057
+ if (kind == 2u) {
3058
+ bool highlighted = (mask & highlightMask) != 0u;
3059
+ float height = badgeHeight(style);
3060
+ if ((flags & 8u) == 0u || highlighted != (overlay == 1) || height < ${12 .toFixed(1)}) {
3061
+ gl_Position = hidden;
3062
+ return;
3063
+ }
3064
+ float font = height * ${badgeFont.toFixed(2)};
3065
+ vec2 center = badgeCenter(texelFetch(nodePosition, t, 0).xy, style, (glyphKind >> 2u) & 3u);
3066
+ vScale = font / atlasFontSize;
3067
+ vInk = paletteColor(glyphKind >> 4u);
3068
+ vHalo = 0.0;
3069
+ gl_Position = toClip(center - vec2(0.0, font * 0.65) + (glyphRect.xy + corner * glyphRect.zw) * vScale);
3070
+ return;
3071
+ }
2761
3072
  float primary = (flags & 4u) != 0u ? 12.0 : 12.0 * min(1.0, zoom);
2762
3073
  if ((flags & 8u) == 0u || look.label < 0.5 || primary < labelMin) {
2763
3074
  gl_Position = hidden;
2764
3075
  return;
2765
3076
  }
2766
- float size = glyphKind == 0u ? primary : primary * 11.0 / 12.0;
3077
+ float size = kind == 0u ? primary : primary * 11.0 / 12.0;
2767
3078
  float scale = size / atlasFontSize;
2768
- float below = nodeRadius(style) + 3.0 + (glyphKind == 1u ? primary * 1.25 : 0.0);
3079
+ float below = nodeRadius(style) + 3.0 + (kind == 1u ? primary * 1.25 : 0.0);
2769
3080
  vec2 anchor = toScreen(texelFetch(nodePosition, t, 0).xy) + vec2(0.0, below);
2770
- vUv = mix(glyphUv.xy, glyphUv.zw, corner);
2771
3081
  vScale = scale;
2772
- vKind = glyphKind;
2773
- vOpacity = look.opacity;
3082
+ vInk = kind == 0u ? labelInk : labelMuted;
3083
+ vHalo = 1.0;
2774
3084
  gl_Position = toClip(anchor + (glyphRect.xy + corner * glyphRect.zw) * scale);
2775
3085
  }
2776
3086
  `;
@@ -2778,21 +3088,78 @@ const labelFragment = `${header}
2778
3088
  uniform sampler2D atlas;
2779
3089
  in vec2 vUv;
2780
3090
  in float vScale;
2781
- flat in uint vKind;
3091
+ flat in vec4 vInk;
3092
+ flat in float vHalo;
2782
3093
  flat in float vOpacity;
2783
3094
  out vec4 fragColor;
2784
3095
  void main() {
2785
3096
  float d = texture(atlas, vUv).r;
2786
3097
  float gamma = 0.08 / max(vScale * pixelRatio, 0.05);
2787
3098
  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;
3099
+ float halo = smoothstep(0.55 - gamma, 0.55 + gamma, d) * vHalo;
3100
+ vec3 rgb = vHalo > 0.5 ? mix(labelHalo.rgb, vInk.rgb, ink) : vInk.rgb;
3101
+ float alpha = max(halo * labelHalo.a, ink * vInk.a) * vOpacity;
2792
3102
  if (alpha < 0.002) discard;
2793
3103
  fragColor = vec4(rgb * alpha, alpha);
2794
3104
  }
2795
3105
  `;
3106
+ const badgeVertex = `${header}${nodeData}
3107
+ in vec2 corner;
3108
+ in uint badgeNode;
3109
+ in uint badgeInfo;
3110
+ in uint badgeFill;
3111
+ in uint badgeRingColor;
3112
+ in float badgeText;
3113
+ out vec2 vLocal;
3114
+ flat out vec2 vHalf;
3115
+ flat out vec4 vFill;
3116
+ flat out vec4 vRing;
3117
+ flat out float vOpacity;
3118
+ void main() {
3119
+ ivec2 t = texel(badgeNode);
3120
+ vec4 style = texelFetch(nodeStyle, t, 0);
3121
+ uvec4 paint = texelFetch(nodePaint, t, 0);
3122
+ uint flags = uint(style.w + 0.5);
3123
+ uint mask = texelFetch(nodeState, t, 0).r;
3124
+ bool highlighted = (mask & highlightMask) != 0u;
3125
+ float height = badgeHeight(style);
3126
+ if ((flags & 8u) == 0u || highlighted != (overlay == 1) || height < ${10 .toFixed(1)}) {
3127
+ gl_Position = hidden;
3128
+ return;
3129
+ }
3130
+ Look look = resolve(mask, Look(style.z, style.y, 0.0, 1.0, paint.x, paint.y), 0);
3131
+ bool text = (badgeInfo & 4u) != 0u && height >= ${12 .toFixed(1)};
3132
+ float font = height * ${badgeFont.toFixed(2)};
3133
+ float width = text ? max(height, badgeText * font / ${24 .toFixed(1)} + height * 0.6) : height;
3134
+ vec2 center = badgeCenter(texelFetch(nodePosition, t, 0).xy, style, badgeInfo & 3u);
3135
+ vHalf = vec2(width, height) * 0.5;
3136
+ vLocal = corner * (vHalf + ${badgeRing.toFixed(1)} + 1.0);
3137
+ vFill = paletteColor(badgeFill);
3138
+ vRing = paletteColor(badgeRingColor);
3139
+ vOpacity = look.opacity;
3140
+ gl_Position = toClip(center + vLocal);
3141
+ }
3142
+ `;
3143
+ const badgeFragment = `${header}
3144
+ in vec2 vLocal;
3145
+ flat in vec2 vHalf;
3146
+ flat in vec4 vFill;
3147
+ flat in vec4 vRing;
3148
+ flat in float vOpacity;
3149
+ out vec4 fragColor;
3150
+ void main() {
3151
+ float aa = 0.5 / pixelRatio + 0.25;
3152
+ vec2 q = vec2(max(abs(vLocal.x) - (vHalf.x - vHalf.y), 0.0), vLocal.y);
3153
+ float d = length(q) - vHalf.y;
3154
+ float inner = 1.0 - smoothstep(-aa, aa, d);
3155
+ float outer = 1.0 - smoothstep(${badgeRing.toFixed(1)} - aa, ${badgeRing.toFixed(1)} + aa, d);
3156
+ vec4 fill = vec4(vFill.rgb * vFill.a, vFill.a);
3157
+ vec4 ring = vec4(vRing.rgb * vRing.a, vRing.a);
3158
+ vec4 result = mix(ring, fill, inner) * outer * vOpacity;
3159
+ if (result.a < 0.002) discard;
3160
+ fragColor = result;
3161
+ }
3162
+ `;
2796
3163
  const cornerNode = new Float32Array([
2797
3164
  -1,
2798
3165
  -1,
@@ -2879,6 +3246,7 @@ const blocks = {
2879
3246
  };
2880
3247
  const paletteWidth = 256;
2881
3248
  const labelInterval = 120;
3249
+ const badgeStride = 20;
2882
3250
  const units = {
2883
3251
  palette: 0,
2884
3252
  nodePosition: 1,
@@ -2888,7 +3256,9 @@ const units = {
2888
3256
  edgeTopology: 5,
2889
3257
  edgeStyle: 6,
2890
3258
  edgeState: 7,
2891
- atlas: 8
3259
+ atlas: 8,
3260
+ images: 9,
3261
+ nodeImage: 10
2892
3262
  };
2893
3263
  function iota(length) {
2894
3264
  const values = new Uint32Array(length);
@@ -2898,7 +3268,7 @@ function iota(length) {
2898
3268
  function rgba(color) {
2899
3269
  return color.map((channel) => channel / 255);
2900
3270
  }
2901
- function createWebglRenderer(canvas) {
3271
+ function createWebglRenderer(canvas, invalidate = () => {}) {
2902
3272
  const context = canvas.getContext("webgl2", {
2903
3273
  alpha: true,
2904
3274
  antialias: false,
@@ -2915,6 +3285,7 @@ function createWebglRenderer(canvas) {
2915
3285
  const nodeStyle = new DataTexture(gl, format.rgba32f, f32);
2916
3286
  const nodePaint = new DataTexture(gl, format.rgba32ui, u32);
2917
3287
  const nodeState = new DataTexture(gl, format.r32ui, u32);
3288
+ const nodeImage = new DataTexture(gl, format.rgba32ui, u32);
2918
3289
  const edgeTopology = new DataTexture(gl, format.rgba32ui, u32);
2919
3290
  const edgeStyle = new DataTexture(gl, format.rgba32f, f32);
2920
3291
  const edgeState = new DataTexture(gl, format.r32ui, u32);
@@ -2922,8 +3293,12 @@ function createWebglRenderer(canvas) {
2922
3293
  nodePosition,
2923
3294
  nodeStyle,
2924
3295
  nodePaint,
2925
- nodeState
3296
+ nodeState,
3297
+ nodeImage
2926
3298
  ];
3299
+ const images = new ImageAtlas(gl, invalidate);
3300
+ const scaleBits = /* @__PURE__ */ new Float32Array(1);
3301
+ const scaleWord = new Uint32Array(scaleBits.buffer);
2927
3302
  const edgeTextures = [
2928
3303
  edgeTopology,
2929
3304
  edgeStyle,
@@ -2957,10 +3332,12 @@ function createWebglRenderer(canvas) {
2957
3332
  const nodeProgram = createProgram(gl, nodeVertex, nodeFragment, blocks);
2958
3333
  const edgeProgram = createProgram(gl, edgeVertex, edgeFragment, blocks);
2959
3334
  const labelProgram = createProgram(gl, labelVertex, labelFragment, blocks);
3335
+ const badgeProgram = createProgram(gl, badgeVertex, badgeFragment, blocks);
2960
3336
  const programs = [
2961
3337
  nodeProgram,
2962
3338
  edgeProgram,
2963
- labelProgram
3339
+ labelProgram,
3340
+ badgeProgram
2964
3341
  ];
2965
3342
  const locations = /* @__PURE__ */ new Map();
2966
3343
  function prepare(program) {
@@ -2988,8 +3365,11 @@ function createWebglRenderer(canvas) {
2988
3365
  const curveIndices = createBuffer(gl, /* @__PURE__ */ new Uint32Array(0), gl.DYNAMIC_DRAW);
2989
3366
  const labelMainBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
2990
3367
  const labelHighlightBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
3368
+ const badgeBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
3369
+ const badgeGlyphBuffer = createBuffer(gl, /* @__PURE__ */ new Uint8Array(0), gl.DYNAMIC_DRAW);
2991
3370
  const warmIndex = createBuffer(gl, /* @__PURE__ */ new Uint32Array(1), gl.STATIC_DRAW);
2992
3371
  const warmGlyph = createBuffer(gl, /* @__PURE__ */ new Uint8Array(32), gl.STATIC_DRAW);
3372
+ const warmBadge = createBuffer(gl, new Uint8Array(badgeStride), gl.STATIC_DRAW);
2993
3373
  const buffers = [
2994
3374
  nodeCorner,
2995
3375
  edgeCorner,
@@ -3002,8 +3382,11 @@ function createWebglRenderer(canvas) {
3002
3382
  curveIndices,
3003
3383
  labelMainBuffer,
3004
3384
  labelHighlightBuffer,
3385
+ badgeBuffer,
3386
+ badgeGlyphBuffer,
3005
3387
  warmIndex,
3006
- warmGlyph
3388
+ warmGlyph,
3389
+ warmBadge
3007
3390
  ];
3008
3391
  const corner = (buffer, size = 2) => ({
3009
3392
  name: "corner",
@@ -3062,18 +3445,39 @@ function createWebglRenderer(canvas) {
3062
3445
  offset: 24
3063
3446
  }
3064
3447
  ];
3448
+ const badgeField = (buffer, name, offset, type = gl.UNSIGNED_INT) => ({
3449
+ name,
3450
+ buffer,
3451
+ size: 1,
3452
+ type,
3453
+ integer: type === gl.UNSIGNED_INT,
3454
+ divisor: 1,
3455
+ stride: badgeStride,
3456
+ offset
3457
+ });
3458
+ const badgeAttributes = (buffer) => [
3459
+ corner(nodeCorner),
3460
+ badgeField(buffer, "badgeNode", 0),
3461
+ badgeField(buffer, "badgeInfo", 4),
3462
+ badgeField(buffer, "badgeFill", 8),
3463
+ badgeField(buffer, "badgeRingColor", 12),
3464
+ badgeField(buffer, "badgeText", 16, gl.FLOAT)
3465
+ ];
3065
3466
  const vertexArrays = {
3066
3467
  nodes: createVertexArray(gl, nodeProgram, [corner(nodeCorner), instance(nodeIndices)]),
3067
3468
  nodeOverlay: createVertexArray(gl, nodeProgram, [corner(nodeCorner), instance(nodeOverlayIndices)]),
3068
3469
  edges: createVertexArray(gl, edgeProgram, [corner(edgeCorner, 3), instance(edgeIndices)]),
3069
3470
  edgeOverlay: createVertexArray(gl, edgeProgram, [corner(edgeCorner, 3), instance(edgeOverlayIndices)]),
3070
3471
  labels: createVertexArray(gl, labelProgram, glyphs(labelMainBuffer)),
3071
- labelHighlight: createVertexArray(gl, labelProgram, glyphs(labelHighlightBuffer))
3472
+ labelHighlight: createVertexArray(gl, labelProgram, glyphs(labelHighlightBuffer)),
3473
+ badges: createVertexArray(gl, badgeProgram, badgeAttributes(badgeBuffer)),
3474
+ badgeGlyphs: createVertexArray(gl, labelProgram, glyphs(badgeGlyphBuffer))
3072
3475
  };
3073
3476
  const vertexCounts = /* @__PURE__ */ new Map([
3074
3477
  [nodeProgram, cornerNode.length / 2],
3075
3478
  [edgeProgram, cornerEdge.length / 3],
3076
- [labelProgram, cornerLabel.length / 2]
3479
+ [labelProgram, cornerLabel.length / 2],
3480
+ [badgeProgram, cornerNode.length / 2]
3077
3481
  ]);
3078
3482
  let warmed = false;
3079
3483
  let curvePass = null;
@@ -3107,6 +3511,13 @@ function createWebglRenderer(canvas) {
3107
3511
  let orderStale = true;
3108
3512
  let lastLabelTime = Number.NEGATIVE_INFINITY;
3109
3513
  let lastLabelView = "";
3514
+ const imageOf = [];
3515
+ const badged = /* @__PURE__ */ new Set();
3516
+ const badgeGlyphs = new GlyphBuffer();
3517
+ let badgeData = /* @__PURE__ */ new ArrayBuffer(1280);
3518
+ let badgeCount = 0;
3519
+ let badgesStale = false;
3520
+ let badgeGeneration = -1;
3110
3521
  function packNode(nodes, index) {
3111
3522
  const at = index * 4;
3112
3523
  const flags = (nodes.shape[index] ?? 0) | (nodes.screenUnits[index] ?? 0) << 2 | (nodes.alive[index] ?? 0) << 3;
@@ -3117,6 +3528,73 @@ function createWebglRenderer(canvas) {
3117
3528
  nodePaint.data[at] = nodes.fill[index] ?? 0;
3118
3529
  nodePaint.data[at + 1] = nodes.stroke[index] ?? 0;
3119
3530
  nodePaint.data[at + 2] = nodes.slot[index] ?? 0;
3531
+ const src = nodes.alive[index] === 1 ? nodes.image[index] ?? null : null;
3532
+ const previous = imageOf[index] ?? null;
3533
+ if (src !== previous) {
3534
+ if (previous) images.release(previous);
3535
+ if (src) images.acquire(src);
3536
+ imageOf[index] = src;
3537
+ }
3538
+ scaleBits[0] = nodes.imageScale[index] ?? 1;
3539
+ nodeImage.data[at] = src ? images.layer(src) + 1 : 0;
3540
+ nodeImage.data[at + 1] = nodes.imageColor[index] ?? 4294967295;
3541
+ nodeImage.data[at + 2] = scaleWord[0] ?? 0;
3542
+ const badges = nodes.alive[index] === 1 ? nodes.badges[index] : null;
3543
+ if (badges || badged.has(index)) badgesStale = true;
3544
+ if (badges) badged.add(index);
3545
+ else badged.delete(index);
3546
+ }
3547
+ function refreshImages(nodes) {
3548
+ const loaded = images.takeLoaded();
3549
+ if (!loaded) return;
3550
+ let first = Number.POSITIVE_INFINITY;
3551
+ let last = -1;
3552
+ for (let index = 0; index < nodes.capacity; index++) {
3553
+ const src = imageOf[index];
3554
+ if (!src || !loaded.has(src)) continue;
3555
+ nodeImage.data[index * 4] = images.layer(src) + 1;
3556
+ first = Math.min(first, index);
3557
+ last = Math.max(last, index);
3558
+ }
3559
+ nodeImage.upload(first, last);
3560
+ }
3561
+ function rebuildBadges() {
3562
+ if (!input) return;
3563
+ const nodes = input.scene.nodes;
3564
+ const generation = atlas.generation;
3565
+ badgeGlyphs.clear();
3566
+ badgeCount = 0;
3567
+ let words = new Uint32Array(badgeData);
3568
+ let floats = new Float32Array(badgeData);
3569
+ for (const index of badged) for (const badge of nodes.badges[index] ?? []) {
3570
+ if ((badgeCount + 1) * badgeStride > badgeData.byteLength) {
3571
+ const grown = /* @__PURE__ */ new ArrayBuffer(badgeData.byteLength * 2);
3572
+ new Uint8Array(grown).set(new Uint8Array(badgeData));
3573
+ badgeData = grown;
3574
+ words = new Uint32Array(badgeData);
3575
+ floats = new Float32Array(badgeData);
3576
+ }
3577
+ const line = badge.text ? atlas.line(badge.text, "500") : null;
3578
+ const at = badgeCount * (badgeStride / 4);
3579
+ words[at] = index;
3580
+ words[at + 1] = badge.corner | (line ? 4 : 0);
3581
+ words[at + 2] = badge.fill;
3582
+ words[at + 3] = badge.ring;
3583
+ floats[at + 4] = line?.width ?? 0;
3584
+ if (line) badgeGlyphs.push(index, 2 | badge.corner << 2 | badge.color << 4, line, atlas.height);
3585
+ badgeCount++;
3586
+ }
3587
+ if (atlas.generation !== generation) {
3588
+ labelsStale = true;
3589
+ rebuildBadges();
3590
+ return;
3591
+ }
3592
+ badgesStale = false;
3593
+ badgeGeneration = atlas.generation;
3594
+ gl.bindBuffer(gl.ARRAY_BUFFER, badgeBuffer);
3595
+ gl.bufferData(gl.ARRAY_BUFFER, new Uint8Array(badgeData, 0, badgeCount * badgeStride), gl.DYNAMIC_DRAW);
3596
+ gl.bindBuffer(gl.ARRAY_BUFFER, badgeGlyphBuffer);
3597
+ gl.bufferData(gl.ARRAY_BUFFER, new Uint8Array(badgeGlyphs.data, 0, badgeGlyphs.count * 32), gl.DYNAMIC_DRAW);
3120
3598
  }
3121
3599
  function packEdge(edges, index) {
3122
3600
  const at = index * 4;
@@ -3156,6 +3634,7 @@ function createWebglRenderer(canvas) {
3156
3634
  }
3157
3635
  nodeStyle.upload(first, last);
3158
3636
  nodePaint.upload(first, last);
3637
+ nodeImage.upload(first, last);
3159
3638
  if (next.positionsChanged || next.full || grown) {
3160
3639
  const xy = next.positions.xy;
3161
3640
  nodePosition.data.set(xy.subarray(0, Math.min(xy.length, nodePosition.data.length)));
@@ -3298,7 +3777,8 @@ function createWebglRenderer(canvas) {
3298
3777
  const passes = [
3299
3778
  [nodeProgram, [corner(nodeCorner), instance(warmIndex)]],
3300
3779
  [edgeProgram, [corner(edgeCorner, 3), instance(warmIndex)]],
3301
- [labelProgram, glyphs(warmGlyph)]
3780
+ [labelProgram, glyphs(warmGlyph)],
3781
+ [badgeProgram, badgeAttributes(warmBadge)]
3302
3782
  ];
3303
3783
  gl.enable(gl.SCISSOR_TEST);
3304
3784
  gl.scissor(0, 0, 0, 0);
@@ -3315,6 +3795,7 @@ function createWebglRenderer(canvas) {
3315
3795
  sync(next) {
3316
3796
  input = next;
3317
3797
  syncNodes(next);
3798
+ refreshImages(next.scene.nodes);
3318
3799
  syncEdges(next);
3319
3800
  syncStates(next);
3320
3801
  if (next.dirty.nodes.size > 0 || next.full) labelsStale = true;
@@ -3351,7 +3832,12 @@ function createWebglRenderer(canvas) {
3351
3832
  frameBlock[7] = 8;
3352
3833
  gl.bindBuffer(gl.UNIFORM_BUFFER, frameBuffer);
3353
3834
  gl.bufferSubData(gl.UNIFORM_BUFFER, 0, frameBlock);
3835
+ if (badgesStale || badgeGeneration !== atlas.generation) rebuildBadges();
3836
+ const generation = atlas.generation;
3354
3837
  const wait = selectLabels(frame, performance.now());
3838
+ if (atlas.generation !== generation) rebuildBadges();
3839
+ syncAtlas();
3840
+ images.prepare();
3355
3841
  gl.viewport(0, 0, width, height);
3356
3842
  gl.clearColor(0, 0, 0, 0);
3357
3843
  gl.clear(gl.COLOR_BUFFER_BIT);
@@ -3366,10 +3852,16 @@ function createWebglRenderer(canvas) {
3366
3852
  draw(pass.program, pass.main, curved.size, layer.slot, 0);
3367
3853
  }
3368
3854
  } else draw(nodeProgram, vertexArrays.nodes, nodeCapacity, layer.slot, 0);
3855
+ draw(badgeProgram, vertexArrays.badges, badgeCount, 0, 0);
3856
+ draw(labelProgram, vertexArrays.badgeGlyphs, badgeGlyphs.count, 0, 0);
3369
3857
  draw(labelProgram, vertexArrays.labels, labels.main.count, 0, 0);
3370
3858
  draw(edgeProgram, vertexArrays.edgeOverlay, edgeOverlayCount, 0, 1);
3371
3859
  if (curvePass && curved.size > 0) draw(curvePass.program, curvePass.overlay, edgeOverlayCount, 0, 1);
3372
3860
  draw(nodeProgram, vertexArrays.nodeOverlay, nodeOverlayCount, 0, 1);
3861
+ if (nodeOverlayCount > 0) {
3862
+ draw(badgeProgram, vertexArrays.badges, badgeCount, 0, 1);
3863
+ draw(labelProgram, vertexArrays.badgeGlyphs, badgeGlyphs.count, 0, 1);
3864
+ }
3373
3865
  draw(labelProgram, vertexArrays.labelHighlight, labels.highlight.count, 0, 1);
3374
3866
  gl.bindVertexArray(null);
3375
3867
  return wait;
@@ -3378,6 +3870,7 @@ function createWebglRenderer(canvas) {
3378
3870
  for (const texture of [...nodeTextures, ...edgeTextures]) texture.destroy();
3379
3871
  gl.deleteTexture(palette);
3380
3872
  gl.deleteTexture(atlasTexture);
3873
+ images.destroy();
3381
3874
  for (const buffer of [
3382
3875
  ...buffers,
3383
3876
  frameBuffer,
@@ -3402,12 +3895,15 @@ function createWebglRenderer(canvas) {
3402
3895
  [units.edgeTopology, edgeTopology.texture],
3403
3896
  [units.edgeStyle, edgeStyle.texture],
3404
3897
  [units.edgeState, edgeState.texture],
3405
- [units.atlas, atlasTexture]
3898
+ [units.atlas, atlasTexture],
3899
+ [units.nodeImage, nodeImage.texture]
3406
3900
  ];
3407
3901
  for (const [unit, texture] of textures) {
3408
3902
  gl.activeTexture(gl.TEXTURE0 + unit);
3409
3903
  gl.bindTexture(gl.TEXTURE_2D, texture);
3410
3904
  }
3905
+ gl.activeTexture(gl.TEXTURE0 + units.images);
3906
+ gl.bindTexture(gl.TEXTURE_2D_ARRAY, images.texture);
3411
3907
  }
3412
3908
  }
3413
3909
  // src/host/events.ts
@@ -3437,8 +3933,71 @@ var Emitter = class {
3437
3933
  this.#handlers.clear();
3438
3934
  }
3439
3935
  };
3936
+ // src/host/keyboard.ts
3937
+ const arrows = {
3938
+ ArrowLeft: {
3939
+ x: -1,
3940
+ y: 0
3941
+ },
3942
+ ArrowRight: {
3943
+ x: 1,
3944
+ y: 0
3945
+ },
3946
+ ArrowUp: {
3947
+ x: 0,
3948
+ y: -1
3949
+ },
3950
+ ArrowDown: {
3951
+ x: 0,
3952
+ y: 1
3953
+ }
3954
+ };
3955
+ const keyZoom = 1.5;
3956
+ function keyAction(event) {
3957
+ if (event.altKey) return null;
3958
+ const command = event.ctrlKey || event.metaKey;
3959
+ const direction = arrows[event.key];
3960
+ if (direction) {
3961
+ if (command) return null;
3962
+ return event.shiftKey ? {
3963
+ type: "pan",
3964
+ direction
3965
+ } : {
3966
+ type: "move",
3967
+ direction
3968
+ };
3969
+ }
3970
+ switch (event.key) {
3971
+ case "Enter":
3972
+ case " ": return {
3973
+ type: "activate",
3974
+ additive: event.shiftKey || command
3975
+ };
3976
+ case "Escape": return { type: "escape" };
3977
+ }
3978
+ if (command) return null;
3979
+ switch (event.key) {
3980
+ case "+":
3981
+ case "=": return {
3982
+ type: "zoom",
3983
+ factor: keyZoom
3984
+ };
3985
+ case "-":
3986
+ case "_": return {
3987
+ type: "zoom",
3988
+ factor: 1 / keyZoom
3989
+ };
3990
+ case "0": return { type: "fit" };
3991
+ }
3992
+ return null;
3993
+ }
3440
3994
  // src/host/state-registry.ts
3441
- const highlightBits = 3;
3995
+ const highlightBits = 7;
3996
+ const highlightStates = [
3997
+ "selected",
3998
+ "hovered",
3999
+ "focused"
4000
+ ];
3442
4001
  const kinds = ["nodes", "edges"];
3443
4002
  function members() {
3444
4003
  return {
@@ -3510,6 +4069,7 @@ var StateRegistry = class {
3510
4069
  apply(changes) {
3511
4070
  let selection = false;
3512
4071
  let hovered = false;
4072
+ let focused = false;
3513
4073
  const forget = (kind, removed) => {
3514
4074
  for (const { key, index } of removed) {
3515
4075
  this.#setMask(kind, index, 0);
@@ -3517,6 +4077,7 @@ var StateRegistry = class {
3517
4077
  if (!this.#members.get(name)?.[kind].delete(key)) continue;
3518
4078
  if (name === "selected") selection = true;
3519
4079
  if (name === "hovered") hovered = true;
4080
+ if (name === "focused") focused = true;
3520
4081
  }
3521
4082
  }
3522
4083
  };
@@ -3529,7 +4090,8 @@ var StateRegistry = class {
3529
4090
  learn("edges", changes.edges.added);
3530
4091
  return {
3531
4092
  selection,
3532
- hovered
4093
+ hovered,
4094
+ focused
3533
4095
  };
3534
4096
  }
3535
4097
  takeHighlighted() {
@@ -3537,7 +4099,7 @@ var StateRegistry = class {
3537
4099
  this.#highlightDirty = false;
3538
4100
  const indices = (kind) => {
3539
4101
  const found = /* @__PURE__ */ new Set();
3540
- for (const name of ["selected", "hovered"]) for (const key of this.#members.get(name)?.[kind] ?? []) {
4102
+ for (const name of highlightStates) for (const key of this.#members.get(name)?.[kind] ?? []) {
3541
4103
  const index = this.#index(kind, key);
3542
4104
  if (index >= 0) found.add(index);
3543
4105
  }
@@ -3582,6 +4144,14 @@ var StateRegistry = class {
3582
4144
  }
3583
4145
  };
3584
4146
  // src/host/mount-graph.ts
4147
+ const svgNamespace = "http://www.w3.org/2000/svg";
4148
+ const lassoStep = 3;
4149
+ const keyPan = .1;
4150
+ const followMargin = 48;
4151
+ const keyDuration = 200;
4152
+ const visuallyHidden = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
4153
+ const focusOutline = "2px solid var(--graph-focus-ring,#c93700)";
4154
+ 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
4155
  const defaultPadding = 32;
3586
4156
  const clickSlop = 4;
3587
4157
  const pickTolerance = 4;
@@ -3636,11 +4206,15 @@ function createGraphHost(container, options, createRenderer) {
3636
4206
  element.setAttribute("data-graph", "");
3637
4207
  element.setAttribute("role", "img");
3638
4208
  element.setAttribute("aria-label", options.ariaLabel);
3639
- element.style.cssText = "position:relative;width:100%;height:100%;overflow:hidden";
4209
+ element.style.cssText = "position:relative;width:100%;height:100%;overflow:hidden;outline:none;outline-offset:-2px";
3640
4210
  const canvas = document.createElement("canvas");
3641
4211
  canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;outline:none";
3642
- element.append(canvas);
4212
+ const announcer = document.createElement("div");
4213
+ announcer.setAttribute("aria-live", "polite");
4214
+ announcer.style.cssText = visuallyHidden;
4215
+ element.append(canvas, announcer);
3643
4216
  container.append(element);
4217
+ let remembered = null;
3644
4218
  let frameRequest = 0;
3645
4219
  let laterRequest;
3646
4220
  let needsFlush = false;
@@ -3668,7 +4242,7 @@ function createGraphHost(container, options, createRenderer) {
3668
4242
  const emitter = new Emitter();
3669
4243
  const pickIndex = new PickIndex();
3670
4244
  const themeResolver = new ThemeResolver(element);
3671
- let renderer = createRenderer(canvas);
4245
+ let renderer = createRenderer(canvas, () => request());
3672
4246
  registry.define(freeStateNames(definition.states));
3673
4247
  if (options.selection !== void 0) registry.set("selected", options.selection);
3674
4248
  const store = createStore({
@@ -3680,6 +4254,7 @@ function createGraphHost(container, options, createRenderer) {
3680
4254
  width: 0,
3681
4255
  height: 0,
3682
4256
  hovered: null,
4257
+ focused: null,
3683
4258
  selection: controlled ? registry.members("selected") : noKeys,
3684
4259
  states: freeStates(),
3685
4260
  positionsVersion: 0
@@ -3872,16 +4447,197 @@ function createGraphHost(container, options, createRenderer) {
3872
4447
  updateCursor();
3873
4448
  request();
3874
4449
  }
4450
+ function nodePoint(key) {
4451
+ const record = model.node(key);
4452
+ return record ? {
4453
+ x: positions.x(record.index),
4454
+ y: positions.y(record.index)
4455
+ } : null;
4456
+ }
4457
+ function focusPoint(focus) {
4458
+ if (focus.kind === "node") return nodePoint(focus.key);
4459
+ const ends = model.endpoints(focus.key);
4460
+ const a = ends ? nodePoint(ends.source) : null;
4461
+ const b = ends ? nodePoint(ends.target) : null;
4462
+ return a && b ? {
4463
+ x: (a.x + b.x) / 2,
4464
+ y: (a.y + b.y) / 2
4465
+ } : null;
4466
+ }
4467
+ function labelOf(focus) {
4468
+ const record = focus.kind === "node" ? model.node(focus.key) : model.edge(focus.key);
4469
+ const labels = focus.kind === "node" ? scene.nodes.label : scene.edges.label;
4470
+ return record && labels[record.index] || String(focus.key);
4471
+ }
4472
+ function announce(text) {
4473
+ announcer.textContent = announcer.textContent === text ? `${text}\u00a0` : text;
4474
+ }
4475
+ function reveal(focus) {
4476
+ const point = focusPoint(focus);
4477
+ if (!point) return;
4478
+ const { view } = store.get();
4479
+ const size = viewport();
4480
+ const screen = toScreen(view, size, point);
4481
+ if (!(screen.x >= followMargin && screen.x <= size.width - followMargin && screen.y >= followMargin && screen.y <= size.height - followMargin)) animateTo({
4482
+ ...view,
4483
+ x: point.x,
4484
+ y: point.y
4485
+ }, keyDuration);
4486
+ }
4487
+ function setFocused(next, follow = false) {
4488
+ if (sameTarget(store.get().focused, next)) return;
4489
+ registry.set("focused", next ? { [kindOf[next.kind]]: [next.key] } : {});
4490
+ patch({ focused: next });
4491
+ if (next) {
4492
+ element.style.outline = "none";
4493
+ announce(labelOf(next));
4494
+ if (follow) reveal(next);
4495
+ }
4496
+ emitter.emit("focuschange", { target: next });
4497
+ request();
4498
+ }
4499
+ function moveFocus(direction) {
4500
+ const current = store.get().focused;
4501
+ const from = current?.kind === "node" ? model.node(current.key)?.index ?? -1 : -1;
4502
+ let index = from >= 0 ? nodeToward(from, direction, scene.nodes, positions) : -1;
4503
+ if (from < 0) {
4504
+ const selected = registry.members("selected").nodes;
4505
+ const last = selected[selected.length - 1];
4506
+ const center = store.get().view;
4507
+ index = last !== void 0 ? model.node(last)?.index ?? -1 : nearestNode((current && focusPoint(current)) ?? center, scene.nodes, positions);
4508
+ }
4509
+ const key = index >= 0 ? scene.nodes.keys[index] : void 0;
4510
+ const next = key !== void 0 ? target("node", key) : null;
4511
+ if (next) setFocused(next, true);
4512
+ }
4513
+ function focusVisible() {
4514
+ try {
4515
+ return element.matches(":focus-visible");
4516
+ } catch {
4517
+ return true;
4518
+ }
4519
+ }
4520
+ function onFocus() {
4521
+ if (!definition.interaction.keyboard || !focusVisible()) return;
4522
+ const restored = remembered ? target(remembered.kind, remembered.key) : null;
4523
+ remembered = null;
4524
+ if (restored) setFocused(restored, true);
4525
+ else if (!store.get().focused) element.style.outline = focusOutline;
4526
+ }
4527
+ function onBlur() {
4528
+ element.style.outline = "none";
4529
+ const current = store.get().focused;
4530
+ if (!current) return;
4531
+ remembered = current;
4532
+ setFocused(null);
4533
+ }
4534
+ function cancelLasso() {
4535
+ if (!gesture?.lasso) return false;
4536
+ if (canvas.hasPointerCapture?.(gesture.id)) canvas.releasePointerCapture(gesture.id);
4537
+ gesture = null;
4538
+ drawLasso(null);
4539
+ updateCursor();
4540
+ return true;
4541
+ }
4542
+ function runKey(event) {
4543
+ const action = keyAction(event);
4544
+ if (!action) return false;
4545
+ const { interaction } = definition;
4546
+ const { view, focused, selection } = store.get();
4547
+ const size = viewport();
4548
+ switch (action.type) {
4549
+ case "move":
4550
+ moveFocus(action.direction);
4551
+ return true;
4552
+ case "pan": {
4553
+ if (!interaction.pan) return false;
4554
+ const step = Math.min(size.width, size.height) * keyPan;
4555
+ commitView(panBy(view, -action.direction.x * step, -action.direction.y * step));
4556
+ return true;
4557
+ }
4558
+ case "zoom": {
4559
+ if (!interaction.zoom) return false;
4560
+ const point = focused ? focusPoint(focused) : null;
4561
+ animateTo(zoomAround(view, size, point ? toScreen(view, size, point) : {
4562
+ x: size.width / 2,
4563
+ y: size.height / 2
4564
+ }, action.factor), keyDuration);
4565
+ return true;
4566
+ }
4567
+ case "fit": return fitTo(void 0, { duration: keyDuration });
4568
+ case "activate":
4569
+ if (!focused || interaction.select === false) return false;
4570
+ selectTarget(focused, action.additive);
4571
+ return true;
4572
+ case "escape":
4573
+ if (cancelLasso()) return true;
4574
+ if (interaction.select !== false && selection.nodes.length + selection.edges.length > 0) {
4575
+ requestSelection(noKeys);
4576
+ return true;
4577
+ }
4578
+ if (!focused) return false;
4579
+ setFocused(null);
4580
+ return true;
4581
+ }
4582
+ }
4583
+ function onKeyDown(event) {
4584
+ if (!definition.interaction.keyboard || event.target !== element) return;
4585
+ if (runKey(event)) event.preventDefault();
4586
+ }
4587
+ function applyKeyboard() {
4588
+ if (definition.interaction.keyboard) {
4589
+ element.tabIndex = 0;
4590
+ element.setAttribute("role", "application");
4591
+ return;
4592
+ }
4593
+ element.removeAttribute("tabindex");
4594
+ element.setAttribute("role", "img");
4595
+ element.style.outline = "none";
4596
+ setFocused(null);
4597
+ }
4598
+ let lassoLayer = null;
4599
+ function drawLasso(points) {
4600
+ if (!points) {
4601
+ lassoLayer?.remove();
4602
+ lassoLayer = null;
4603
+ return;
4604
+ }
4605
+ if (!lassoLayer) {
4606
+ lassoLayer = document.createElementNS(svgNamespace, "svg");
4607
+ lassoLayer.setAttribute("aria-hidden", "true");
4608
+ lassoLayer.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible";
4609
+ const path = document.createElementNS(svgNamespace, "path");
4610
+ path.style.cssText = lassoStyle;
4611
+ lassoLayer.append(path);
4612
+ element.append(lassoLayer);
4613
+ }
4614
+ lassoLayer.firstElementChild?.setAttribute("d", `M${points.map((point) => `${point.x} ${point.y}`).join("L")}Z`);
4615
+ }
4616
+ function selectInside(points) {
4617
+ const view = store.get().view;
4618
+ const size = viewport();
4619
+ const inside = nodesInside(points.map((point) => toWorld(view, size, point)), scene.nodes, positions).filter((key) => !registry.has("selected", "nodes", key));
4620
+ if (inside.length === 0) return;
4621
+ const current = registry.members("selected");
4622
+ requestSelection({
4623
+ ...current,
4624
+ nodes: [...current.nodes, ...inside]
4625
+ });
4626
+ }
3875
4627
  function updateCursor() {
3876
4628
  const interactive = definition.interaction.select !== false || definition.interaction.drag;
3877
- if (gesture?.moved) canvas.style.cursor = "grabbing";
4629
+ if (gesture?.lasso) canvas.style.cursor = "crosshair";
4630
+ else if (gesture?.moved) canvas.style.cursor = "grabbing";
3878
4631
  else if (store.get().hovered && interactive) canvas.style.cursor = "pointer";
3879
4632
  else canvas.style.cursor = "";
3880
4633
  }
3881
4634
  function selectOnClick(clicked, event) {
4635
+ selectTarget(clicked, event.shiftKey || event.metaKey || event.ctrlKey);
4636
+ }
4637
+ function selectTarget(clicked, modifier) {
3882
4638
  const mode = definition.interaction.select;
3883
4639
  if (mode === false) return;
3884
- const additive = mode === "multiple" && (event.shiftKey || event.metaKey || event.ctrlKey);
4640
+ const additive = mode === "multiple" && modifier;
3885
4641
  if (!clicked) {
3886
4642
  if (!additive) requestSelection(noKeys);
3887
4643
  return;
@@ -3920,6 +4676,14 @@ function createGraphHost(container, options, createRenderer) {
3920
4676
  const fresh = target(hovered.kind, hovered.key);
3921
4677
  if (fresh && fresh.datum !== hovered.datum) patch({ hovered: fresh });
3922
4678
  }
4679
+ const focused = store.get().focused;
4680
+ if (result.focused) {
4681
+ patch({ focused: null });
4682
+ emitter.emit("focuschange", { target: null });
4683
+ } else if (focused) {
4684
+ const fresh = target(focused.kind, focused.key);
4685
+ if (fresh && fresh.datum !== focused.datum) patch({ focused: fresh });
4686
+ }
3923
4687
  }
3924
4688
  function movePositions(indices, to, duration) {
3925
4689
  if (indices.length === 0) return;
@@ -4101,18 +4865,24 @@ function createGraphHost(container, options, createRenderer) {
4101
4865
  function onPointerDown(event) {
4102
4866
  if (event.button !== 0 || gesture) return;
4103
4867
  viewAnimation = null;
4868
+ remembered = null;
4869
+ setFocused(null);
4104
4870
  const point = local(event);
4105
4871
  const hit = pickAt(point);
4106
4872
  const drag = hit ? startDrag(hit, point) : null;
4873
+ const { lasso, select } = definition.interaction;
4874
+ const lassoing = !hit && event.shiftKey && lasso && select === "multiple";
4107
4875
  gesture = {
4108
4876
  id: event.pointerId,
4109
4877
  start: point,
4110
4878
  last: point,
4111
4879
  target: hit,
4112
4880
  drag,
4881
+ lasso: lassoing ? [point] : null,
4113
4882
  moved: false
4114
4883
  };
4115
4884
  canvas.setPointerCapture?.(event.pointerId);
4885
+ if (lassoing) updateCursor();
4116
4886
  }
4117
4887
  function onPointerMove(event) {
4118
4888
  const point = local(event);
@@ -4125,6 +4895,16 @@ function createGraphHost(container, options, createRenderer) {
4125
4895
  gesture.moved = true;
4126
4896
  updateCursor();
4127
4897
  }
4898
+ const lasso = gesture.lasso;
4899
+ if (lasso) {
4900
+ const last = lasso[lasso.length - 1] ?? gesture.start;
4901
+ if (gesture.moved && Math.hypot(point.x - last.x, point.y - last.y) >= lassoStep) {
4902
+ lasso.push(point);
4903
+ drawLasso(lasso);
4904
+ }
4905
+ gesture.last = point;
4906
+ return;
4907
+ }
4128
4908
  const drag = gesture.drag;
4129
4909
  if (drag) {
4130
4910
  if (!gesture.moved) return;
@@ -4161,6 +4941,10 @@ function createGraphHost(container, options, createRenderer) {
4161
4941
  gesture = null;
4162
4942
  if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
4163
4943
  if (ended.drag?.started) emitter.emit("dragend", dragEvent(ended.drag, event));
4944
+ if (ended.lasso) {
4945
+ drawLasso(null);
4946
+ if (!cancelled && ended.moved) selectInside(ended.lasso);
4947
+ }
4164
4948
  if (!cancelled && !ended.moved) {
4165
4949
  emitter.emit("click", pointerEvent(event, ended.target));
4166
4950
  selectOnClick(ended.target, event);
@@ -4198,7 +4982,7 @@ function createGraphHost(container, options, createRenderer) {
4198
4982
  }
4199
4983
  function onContextRestored() {
4200
4984
  contextLost = false;
4201
- renderer = createRenderer(canvas);
4985
+ renderer = createRenderer(canvas, () => request());
4202
4986
  full = true;
4203
4987
  refreshTheme();
4204
4988
  }
@@ -4219,6 +5003,13 @@ function createGraphHost(container, options, createRenderer) {
4219
5003
  ["webglcontextrestored", onContextRestored]
4220
5004
  ];
4221
5005
  for (const [name, listener, listenerOptions] of listeners) canvas.addEventListener(name, listener, listenerOptions);
5006
+ const elementListeners = [
5007
+ ["keydown", onKeyDown],
5008
+ ["focus", onFocus],
5009
+ ["blur", onBlur]
5010
+ ];
5011
+ for (const [name, listener] of elementListeners) element.addEventListener(name, listener);
5012
+ applyKeyboard();
4222
5013
  model.setMarks(definition.marks);
4223
5014
  scene.setDefinition(definition);
4224
5015
  applyLayers();
@@ -4253,6 +5044,15 @@ function createGraphHost(container, options, createRenderer) {
4253
5044
  set: (keys) => requestSelection(toKeys(keys)),
4254
5045
  clear: () => requestSelection(noKeys)
4255
5046
  },
5047
+ focus: {
5048
+ get: () => store.get().focused,
5049
+ set: (next) => {
5050
+ const resolved = next ? target(next.kind, next.key) : null;
5051
+ if (next && !resolved) return;
5052
+ if (resolved && definition.interaction.keyboard) element.focus({ preventScroll: true });
5053
+ setFocused(resolved, true);
5054
+ }
5055
+ },
4256
5056
  states: {
4257
5057
  set: (name, keys) => {
4258
5058
  if (registry.set(declared(name), keys)) publishState(name);
@@ -4315,6 +5115,7 @@ function createGraphHost(container, options, createRenderer) {
4315
5115
  applyLayers();
4316
5116
  if (registry.define(freeStateNames(definition.states))) patch({ states: freeStates() });
4317
5117
  applyStyles();
5118
+ applyKeyboard();
4318
5119
  updateCursor();
4319
5120
  request(true);
4320
5121
  }
@@ -4327,6 +5128,7 @@ function createGraphHost(container, options, createRenderer) {
4327
5128
  resizeObserver.disconnect();
4328
5129
  stopTheme();
4329
5130
  for (const [name, listener] of listeners) canvas.removeEventListener(name, listener);
5131
+ for (const [name, listener] of elementListeners) element.removeEventListener(name, listener);
4330
5132
  emitter.clear();
4331
5133
  model.destroy();
4332
5134
  renderer.destroy();
@@ -4340,4 +5142,4 @@ function mountGraph(container, options) {
4340
5142
  }
4341
5143
  export { freeStateNames as n, maxFreeStates as r, mountGraph as t };
4342
5144
 
4343
- //# sourceMappingURL=mount-graph-DYOgD_ql.js.map
5145
+ //# sourceMappingURL=mount-graph-CzVfiIte.js.map