@ngroznykh/papirus 0.9.12 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/papirus.js CHANGED
@@ -3220,6 +3220,20 @@ function measureTextLines(ctx, lines) {
3220
3220
  for (const line of lines) maxWidth = Math.max(maxWidth, ctx.measureText(line).width);
3221
3221
  return maxWidth;
3222
3222
  }
3223
+ function readScale(ctx) {
3224
+ if (typeof ctx.getTransform !== "function") return null;
3225
+ const m = ctx.getTransform();
3226
+ if (m === null || typeof m.a !== "number") return null;
3227
+ return {
3228
+ a: m.a,
3229
+ b: typeof m.b === "number" ? m.b : 0
3230
+ };
3231
+ }
3232
+ function isScreenFontReadable(ctx, fontSize) {
3233
+ const m = readScale(ctx);
3234
+ if (m === null) return true;
3235
+ return fontSize * Math.hypot(m.a, m.b) >= 7;
3236
+ }
3223
3237
  //#endregion
3224
3238
  //#region src/elements/TextLabel.ts
3225
3239
  var DEFAULT_INSET = 8;
@@ -3477,10 +3491,10 @@ var TextLabel = class {
3477
3491
  default: startY = innerBounds.y + (innerBounds.height - totalHeight) / 2 + lineHeight / 2;
3478
3492
  }
3479
3493
  ctx.fillStyle = this._style.color ?? "#000000";
3494
+ if (!isScreenFontReadable(ctx, this._style.fontSize ?? 14)) return;
3480
3495
  for (let i = 0; i < this._lines.length; i++) {
3481
- const line = this._lines[i];
3482
3496
  const y = startY + i * lineHeight;
3483
- ctx.fillText(line, x, y);
3497
+ ctx.fillText(this._lines[i], x, y);
3484
3498
  }
3485
3499
  }
3486
3500
  /**
@@ -6411,6 +6425,7 @@ function drawLegacyArrowHead(ctx, from, to) {
6411
6425
  function renderEdgeLabel(ctx, options) {
6412
6426
  const { label, path, background } = options;
6413
6427
  if (!label || path.length < 2) return;
6428
+ if (!isScreenFontReadable(ctx, label.style.fontSize ?? 14)) return;
6414
6429
  const labelCenter = getEdgeLabelPosition(options);
6415
6430
  if (!labelCenter) return;
6416
6431
  const rotation = getEdgeLabelRotation(options);
@@ -7060,7 +7075,7 @@ var Edge = class extends Element {
7060
7075
  }
7061
7076
  return pts;
7062
7077
  };
7063
- if (!(this._label !== void 0 && this._labelLineGap)) {
7078
+ if (!(this._label !== void 0 && this._labelLineGap && isScreenFontReadable(ctx, this._label.style.fontSize ?? 14))) {
7064
7079
  const start = path[0];
7065
7080
  const startNext = path[1];
7066
7081
  const startAngle = Math.atan2(startNext.y - start.y, startNext.x - start.x);
@@ -7547,6 +7562,7 @@ var InteractionManager = class {
7547
7562
  this.handledScrollbarMouseDown = false;
7548
7563
  this.overlayDragSession = null;
7549
7564
  this.handledOverlayMouseDown = false;
7565
+ this.editHoverSuspendedForPan = false;
7550
7566
  this.renderer = options.renderer;
7551
7567
  this.navigationOnly = options.navigationOnly ?? false;
7552
7568
  this.inputHandler = new InputHandler({
@@ -7730,7 +7746,7 @@ var InteractionManager = class {
7730
7746
  setupEvents() {
7731
7747
  this.overlayCleanup = this.renderer.addOverlayRenderer((ctx) => {
7732
7748
  this.selectionManager.renderSelectionRect(ctx);
7733
- if (!this.navigationOnly) {
7749
+ if (!this.navigationOnly && !this.navigationManager.panning) {
7734
7750
  this.dragManager.renderAlignmentGuides(ctx);
7735
7751
  this.connectionManager.renderPreview(ctx);
7736
7752
  this.connectionManager.renderHoverAnchors(ctx);
@@ -7901,7 +7917,7 @@ var InteractionManager = class {
7901
7917
  if (event.button === 0) {
7902
7918
  if (this.navigationOnly) {
7903
7919
  this.selectionManager.clearSelection();
7904
- this.navigationManager.startPan(event);
7920
+ this.beginBackgroundPan(event);
7905
7921
  return;
7906
7922
  }
7907
7923
  if (hitElement === void 0) {
@@ -7910,11 +7926,14 @@ var InteractionManager = class {
7910
7926
  return;
7911
7927
  }
7912
7928
  this.selectionManager.clearSelection();
7913
- this.navigationManager.startPan(event);
7929
+ this.beginBackgroundPan(event);
7914
7930
  return;
7915
7931
  }
7916
7932
  }
7917
- if (this.navigationManager.handleMouseDown(event)) return;
7933
+ if (this.navigationManager.handleMouseDown(event)) {
7934
+ this.suspendEditHover();
7935
+ return;
7936
+ }
7918
7937
  if (this.navigationOnly) return;
7919
7938
  if (this.dragManager.handleMouseDown(event)) return;
7920
7939
  }
@@ -7928,7 +7947,11 @@ var InteractionManager = class {
7928
7947
  this.renderer.setScrollbarActiveAxis(null);
7929
7948
  this.scrollbarDragState = null;
7930
7949
  }
7931
- if (this.navigationManager.panning) this.navigationManager.handleMouseUp(event);
7950
+ if (this.navigationManager.panning) this.finishBackgroundPan(event);
7951
+ }
7952
+ if (this.navigationManager.panning) {
7953
+ this.navigationManager.handleMouseMove(event);
7954
+ return;
7932
7955
  }
7933
7956
  const overScrollbar = this.renderer.updateScrollbarHover(event.screenX, event.screenY);
7934
7957
  const overOverlayPointerSink = this.renderer.blocksDiagramPointerAtScreen(event.screenX, event.screenY);
@@ -7990,7 +8013,7 @@ var InteractionManager = class {
7990
8013
  this.selectionManager.endSelectionRect();
7991
8014
  return;
7992
8015
  }
7993
- this.navigationManager.handleMouseUp(event);
8016
+ this.finishBackgroundPan(event);
7994
8017
  }
7995
8018
  handleClick(event) {
7996
8019
  if (this.handledOverlayMouseDown) {
@@ -8094,6 +8117,24 @@ var InteractionManager = class {
8094
8117
  }
8095
8118
  return closestEdge;
8096
8119
  }
8120
+ suspendEditHover() {
8121
+ if (this.navigationOnly || this.editHoverSuspendedForPan) return;
8122
+ this.connectionManager.disableHover();
8123
+ this.editHoverSuspendedForPan = true;
8124
+ }
8125
+ resumeEditHover() {
8126
+ if (!this.editHoverSuspendedForPan) return;
8127
+ this.connectionManager.enableHover();
8128
+ this.editHoverSuspendedForPan = false;
8129
+ }
8130
+ beginBackgroundPan(event) {
8131
+ this.suspendEditHover();
8132
+ this.navigationManager.startPan(event);
8133
+ }
8134
+ finishBackgroundPan(event) {
8135
+ this.navigationManager.handleMouseUp(event);
8136
+ this.resumeEditHover();
8137
+ }
8097
8138
  handleWheel(event) {
8098
8139
  if (this.renderer.blocksDiagramPointerAtScreen(event.screenX, event.screenY)) return;
8099
8140
  this.navigationManager.handleWheel(event);
@@ -8132,6 +8173,7 @@ var InteractionManager = class {
8132
8173
  }
8133
8174
  handleKeyUp(event) {
8134
8175
  this.navigationManager.handleKeyUp(event);
8176
+ if (!this.navigationManager.panning) this.resumeEditHover();
8135
8177
  }
8136
8178
  handleViewportNavigationKey(event) {
8137
8179
  switch (event.key) {
@@ -8316,6 +8358,66 @@ var InteractionManager = class {
8316
8358
  }
8317
8359
  };
8318
8360
  //#endregion
8361
+ //#region src/utils/pathViewport.ts
8362
+ /**
8363
+ * True when the drawn edge path meets the viewport.
8364
+ * AABB-only cull keeps fat bezier control boxes on the walk even when the
8365
+ * curve never enters the view. Empty path stays visible for the first layout.
8366
+ */
8367
+ function edgePathIntersectsViewport(input, viewport) {
8368
+ const path = input.path;
8369
+ if (path.length === 0) return true;
8370
+ const aabb = boundsFromPoints(path);
8371
+ if (aabb === null || !rectsIntersect(aabb, viewport)) return false;
8372
+ if (input.type === "bezier" && path.length >= 4) return polylineIntersectsRect(sampleBezier(path), viewport);
8373
+ return polylineIntersectsRect(path, viewport);
8374
+ }
8375
+ function boundsFromPoints(points) {
8376
+ if (points.length === 0) return null;
8377
+ let minX = Infinity;
8378
+ let minY = Infinity;
8379
+ let maxX = -Infinity;
8380
+ let maxY = -Infinity;
8381
+ for (const point of points) {
8382
+ minX = Math.min(minX, point.x);
8383
+ minY = Math.min(minY, point.y);
8384
+ maxX = Math.max(maxX, point.x);
8385
+ maxY = Math.max(maxY, point.y);
8386
+ }
8387
+ return {
8388
+ x: minX,
8389
+ y: minY,
8390
+ width: maxX - minX,
8391
+ height: maxY - minY
8392
+ };
8393
+ }
8394
+ function polylineIntersectsRect(points, rect) {
8395
+ if (points.length === 1) return pointInRect(points[0], rect);
8396
+ for (let i = 0; i < points.length - 1; i++) {
8397
+ const start = points[i];
8398
+ const end = points[i + 1];
8399
+ if (pointInRect(start, rect) || pointInRect(end, rect)) return true;
8400
+ if (segmentRectIntersections(start, end, rect).length > 0) return true;
8401
+ }
8402
+ return false;
8403
+ }
8404
+ function sampleBezier(path) {
8405
+ const samples = [];
8406
+ const steps = 20;
8407
+ for (let i = 1; i + 2 < path.length; i += 3) {
8408
+ const p0 = path[i - 1];
8409
+ const p1 = path[i];
8410
+ const p2 = path[i + 1];
8411
+ const p3 = path[i + 2];
8412
+ for (let s = 0; s <= steps; s++) {
8413
+ const t = s / steps;
8414
+ if (samples.length > 0 && t === 0) continue;
8415
+ samples.push(bezierPoint(p0, p1, p2, p3, t));
8416
+ }
8417
+ }
8418
+ return samples;
8419
+ }
8420
+ //#endregion
8319
8421
  //#region src/core/AnimationManager.ts
8320
8422
  var DEFAULT_OPTIONS$3 = {
8321
8423
  enabled: false,
@@ -9294,6 +9396,8 @@ var DiagramRenderer = class extends EventEmitter {
9294
9396
  this._dirty = true;
9295
9397
  this._styleDirty = true;
9296
9398
  this._contentDirty = true;
9399
+ this._contentRevision = 0;
9400
+ this._contentBoundsCache = null;
9297
9401
  this._destroyed = false;
9298
9402
  this._contextLost = false;
9299
9403
  this._canvasRect = null;
@@ -9730,7 +9834,7 @@ var DiagramRenderer = class extends EventEmitter {
9730
9834
  addGroup(group) {
9731
9835
  group.setDirtyListener(() => this.markDirty());
9732
9836
  this._groups.set(group.id, group);
9733
- this.markDirty();
9837
+ this.markContentDirty();
9734
9838
  }
9735
9839
  /**
9736
9840
  * Remove a group from the diagram
@@ -9740,7 +9844,7 @@ var DiagramRenderer = class extends EventEmitter {
9740
9844
  if (group === void 0) return false;
9741
9845
  this._groups.delete(groupId);
9742
9846
  group.setDirtyListener(void 0);
9743
- this.markDirty();
9847
+ this.markContentDirty();
9744
9848
  return true;
9745
9849
  }
9746
9850
  /**
@@ -9814,10 +9918,18 @@ var DiagramRenderer = class extends EventEmitter {
9814
9918
  */
9815
9919
  markContentDirty() {
9816
9920
  this._contentDirty = true;
9921
+ this._contentRevision += 1;
9817
9922
  this._nodeObstaclesCache = null;
9818
9923
  this._dirty = true;
9819
9924
  }
9820
9925
  /**
9926
+ * Monotonic counter bumped when nodes/edges/layout change (not on pan/zoom).
9927
+ * Used by MiniMap to cache its content bake across viewport moves.
9928
+ */
9929
+ getContentRevision() {
9930
+ return this._contentRevision;
9931
+ }
9932
+ /**
9821
9933
  * Visible world rectangle plus padding so labels and arrow markers are not culled early.
9822
9934
  */
9823
9935
  getViewportWorldBounds() {
@@ -9835,10 +9947,13 @@ var DiagramRenderer = class extends EventEmitter {
9835
9947
  }
9836
9948
  /**
9837
9949
  * Edges without a computed path stay drawable so the first layout pass can paint them.
9950
+ * Bezier control-box AABB is not enough — walk the actual path.
9838
9951
  */
9839
9952
  edgeIntersectsViewport(edge, viewport) {
9840
- if (edge.path.length === 0) return true;
9841
- return this.intersectsViewport(edge.getBounds(), viewport);
9953
+ return edgePathIntersectsViewport({
9954
+ path: edge.path,
9955
+ type: edge.type
9956
+ }, viewport);
9842
9957
  }
9843
9958
  /**
9844
9959
  * Force an immediate render
@@ -9938,13 +10053,13 @@ var DiagramRenderer = class extends EventEmitter {
9938
10053
  applyStyleManagerToElements(this.styleManager, this._groups.values(), this._edges.values(), this._nodes.values());
9939
10054
  this._styleDirty = false;
9940
10055
  }
9941
- for (const group of this._groups.values()) group.recalculateBounds();
9942
10056
  const viewport = this.getViewportWorldBounds();
9943
10057
  for (const group of this._groups.values()) if (group.visible && this.intersectsViewport(group.getBounds(), viewport)) {
9944
10058
  this.renderElementWithAnimation(ctx, group, () => group.render(ctx));
9945
10059
  group.clearDirty();
9946
10060
  }
9947
10061
  if (this._contentDirty) {
10062
+ for (const group of this._groups.values()) group.recalculateBounds();
9948
10063
  this.edgeEndpointUpdater.updateAll();
9949
10064
  this._contentDirty = false;
9950
10065
  }
@@ -10007,11 +10122,17 @@ var DiagramRenderer = class extends EventEmitter {
10007
10122
  return this._nodeObstaclesCache;
10008
10123
  }
10009
10124
  getContentBounds() {
10010
- return getContentBounds({
10125
+ if (this._contentBoundsCache?.revision === this._contentRevision) return this._contentBoundsCache.bounds;
10126
+ const bounds = getContentBounds({
10011
10127
  nodes: this._nodes.values(),
10012
10128
  edges: this._edges.values(),
10013
10129
  groups: this._groups.values()
10014
10130
  });
10131
+ this._contentBoundsCache = {
10132
+ revision: this._contentRevision,
10133
+ bounds
10134
+ };
10135
+ return bounds;
10015
10136
  }
10016
10137
  notifyScrollbarInteraction() {
10017
10138
  this.scrollbarController.notifyInteraction();
@@ -11947,6 +12068,7 @@ var Group = class Group extends Element {
11947
12068
  */
11948
12069
  recalculateBounds() {
11949
12070
  if (this._children.length === 0) {
12071
+ if (this._x === 0 && this._y === 0 && this._width === 0 && this._height === 0) return;
11950
12072
  this._x = 0;
11951
12073
  this._y = 0;
11952
12074
  this._width = 0;
@@ -11965,10 +12087,15 @@ var Group = class Group extends Element {
11965
12087
  maxX = Math.max(maxX, bounds.x + bounds.width);
11966
12088
  maxY = Math.max(maxY, bounds.y + bounds.height);
11967
12089
  }
11968
- this._x = minX - this._padding;
11969
- this._y = minY - this._padding;
11970
- this._width = maxX - minX + this._padding * 2;
11971
- this._height = maxY - minY + this._padding * 2;
12090
+ const nextX = minX - this._padding;
12091
+ const nextY = minY - this._padding;
12092
+ const nextWidth = maxX - minX + this._padding * 2;
12093
+ const nextHeight = maxY - minY + this._padding * 2;
12094
+ if (this._x === nextX && this._y === nextY && this._width === nextWidth && this._height === nextHeight) return;
12095
+ this._x = nextX;
12096
+ this._y = nextY;
12097
+ this._width = nextWidth;
12098
+ this._height = nextHeight;
11972
12099
  this.markDirty();
11973
12100
  }
11974
12101
  hitTest(point) {
@@ -12856,6 +12983,7 @@ var CText = class extends CompositeComponentBase {
12856
12983
  if (this.style.visible === false) return;
12857
12984
  const opacity = this.style.opacity ?? 1;
12858
12985
  if (opacity <= 0) return;
12986
+ if (!isScreenFontReadable(ctx, this._fontSize)) return;
12859
12987
  ctx.save();
12860
12988
  ctx.globalAlpha *= opacity;
12861
12989
  ctx.font = this.getFont();
@@ -12908,8 +13036,14 @@ var CText = class extends CompositeComponentBase {
12908
13036
  ctx.rotate(this._rotation * Math.PI / 180);
12909
13037
  const offsetX = xBase - (textBounds.x + textBounds.width / 2);
12910
13038
  const offsetY = yStart - (textBounds.y + textBounds.height / 2);
12911
- for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], offsetX, offsetY + i * lineHeightPx);
12912
- } else for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], xBase, yStart + i * lineHeightPx);
13039
+ for (let i = 0; i < lines.length; i++) {
13040
+ const y = offsetY + i * lineHeightPx;
13041
+ ctx.fillText(lines[i], offsetX, y);
13042
+ }
13043
+ } else for (let i = 0; i < lines.length; i++) {
13044
+ const y = yStart + i * lineHeightPx;
13045
+ ctx.fillText(lines[i], xBase, y);
13046
+ }
12913
13047
  ctx.restore();
12914
13048
  }
12915
13049
  serialize() {
@@ -15546,6 +15680,24 @@ function distributeNodes(nodes, type) {
15546
15680
  }
15547
15681
  }
15548
15682
  }
15683
+ /**
15684
+ * World step that keeps the visible grid cell in a Miro-like range.
15685
+ * The base `gridSize` is doubled or halved so the screen size stays ~16–32 px.
15686
+ */
15687
+ function adaptiveGridStep(gridSize, zoom) {
15688
+ if (!Number.isFinite(gridSize) || !Number.isFinite(zoom) || gridSize <= 0 || zoom <= 0) return gridSize;
15689
+ let step = gridSize;
15690
+ let screen = step * zoom;
15691
+ while (screen < 16) {
15692
+ step *= 2;
15693
+ screen *= 2;
15694
+ }
15695
+ while (screen > 32) {
15696
+ step /= 2;
15697
+ screen /= 2;
15698
+ }
15699
+ return step;
15700
+ }
15549
15701
  //#endregion
15550
15702
  //#region src/core/overlays/BaseOverlay.ts
15551
15703
  /**
@@ -15584,20 +15736,20 @@ var GridOverlay = class extends BaseOverlay {
15584
15736
  if (!this.enabled) return;
15585
15737
  const width = renderer.width;
15586
15738
  const height = renderer.height;
15587
- const gridSize = this.gridSize;
15588
- const color = this.color;
15589
- const startX = Math.floor(-renderer.offsetX / renderer.zoom / gridSize) * gridSize;
15590
- const startY = Math.floor(-renderer.offsetY / renderer.zoom / gridSize) * gridSize;
15591
- const endX = Math.ceil((width - renderer.offsetX) / renderer.zoom / gridSize) * gridSize;
15592
- const endY = Math.ceil((height - renderer.offsetY) / renderer.zoom / gridSize) * gridSize;
15593
- ctx.strokeStyle = color;
15594
- ctx.lineWidth = 1 / renderer.zoom;
15739
+ const zoom = renderer.zoom;
15740
+ const step = adaptiveGridStep(this.gridSize, zoom);
15741
+ const startX = Math.floor(-renderer.offsetX / zoom / step) * step;
15742
+ const startY = Math.floor(-renderer.offsetY / zoom / step) * step;
15743
+ const endX = Math.ceil((width - renderer.offsetX) / zoom / step) * step;
15744
+ const endY = Math.ceil((height - renderer.offsetY) / zoom / step) * step;
15745
+ ctx.strokeStyle = this.color;
15746
+ ctx.lineWidth = 1 / zoom;
15595
15747
  ctx.beginPath();
15596
- for (let x = startX; x <= endX; x += gridSize) {
15748
+ for (let x = startX; x <= endX; x += step) {
15597
15749
  ctx.moveTo(x, startY);
15598
15750
  ctx.lineTo(x, endY);
15599
15751
  }
15600
- for (let y = startY; y <= endY; y += gridSize) {
15752
+ for (let y = startY; y <= endY; y += step) {
15601
15753
  ctx.moveTo(startX, y);
15602
15754
  ctx.lineTo(endX, y);
15603
15755
  }
@@ -15610,51 +15762,114 @@ var GridOverlay = class extends BaseOverlay {
15610
15762
  var RulersOverlay = class extends BaseOverlay {
15611
15763
  constructor(options = {}) {
15612
15764
  super(options.enabled ?? true);
15765
+ this.tickBake = null;
15766
+ this.tickBakeGeneration = 0;
15613
15767
  this.thickness = options.thickness ?? 20;
15614
15768
  this.backgroundColor = options.backgroundColor ?? "#f4f4f5";
15615
15769
  this.textColor = options.textColor ?? "#52525b";
15616
15770
  this.tickColor = options.tickColor ?? "#a1a1aa";
15617
15771
  }
15772
+ getTickBakeGeneration() {
15773
+ return this.tickBakeGeneration;
15774
+ }
15618
15775
  install(renderer) {
15619
15776
  this.removeOverlay = renderer.addOverlayRenderer((ctx) => {
15620
15777
  if (!this.enabled) return;
15621
15778
  const thickness = this.thickness;
15779
+ const zoom = renderer.zoom;
15780
+ const worldLeft = -renderer.offsetX / zoom;
15781
+ const worldTop = -renderer.offsetY / zoom;
15622
15782
  ctx.save();
15623
15783
  ctx.setTransform(renderer.pixelRatio, 0, 0, renderer.pixelRatio, 0, 0);
15624
15784
  ctx.fillStyle = this.backgroundColor;
15625
15785
  ctx.fillRect(0, 0, renderer.width, thickness);
15626
15786
  ctx.fillRect(0, 0, thickness, renderer.height);
15627
- ctx.strokeStyle = this.tickColor;
15628
- ctx.fillStyle = this.textColor;
15629
- ctx.font = "10px sans-serif";
15630
- ctx.textAlign = "left";
15631
- ctx.textBaseline = "top";
15632
- const step = this.getTickStep(renderer.zoom);
15633
- const worldLeft = -renderer.offsetX / renderer.zoom;
15634
- const worldTop = -renderer.offsetY / renderer.zoom;
15635
- const worldRight = worldLeft + renderer.width / renderer.zoom;
15636
- const worldBottom = worldTop + renderer.height / renderer.zoom;
15637
- ctx.beginPath();
15638
- for (let x = Math.floor(worldLeft / step) * step; x <= worldRight; x += step) {
15639
- const screenX = x * renderer.zoom + renderer.offsetX;
15640
- ctx.moveTo(screenX, thickness);
15641
- ctx.lineTo(screenX, thickness - 6);
15642
- ctx.fillText(`${Math.round(x)}`, screenX + 2, 2);
15643
- }
15644
- for (let y = Math.floor(worldTop / step) * step; y <= worldBottom; y += step) {
15645
- const screenY = y * renderer.zoom + renderer.offsetY;
15646
- ctx.moveTo(thickness, screenY);
15647
- ctx.lineTo(thickness - 6, screenY);
15648
- ctx.save();
15649
- ctx.translate(2, screenY + 2);
15650
- ctx.rotate(-Math.PI / 2);
15651
- ctx.fillText(`${Math.round(y)}`, 0, 0);
15652
- ctx.restore();
15653
- }
15654
- ctx.stroke();
15787
+ const bake = this.ensureTickBake(renderer);
15788
+ const srcX = (worldLeft - bake.worldLeft) * zoom;
15789
+ const srcY = (worldTop - bake.worldTop) * zoom;
15790
+ const pr = renderer.pixelRatio;
15791
+ ctx.drawImage(bake.horizontal, srcX * pr, 0, renderer.width * pr, thickness * pr, 0, 0, renderer.width, thickness);
15792
+ ctx.drawImage(bake.vertical, 0, srcY * pr, thickness * pr, renderer.height * pr, 0, 0, thickness, renderer.height);
15655
15793
  ctx.restore();
15656
15794
  });
15657
15795
  }
15796
+ ensureTickBake(renderer) {
15797
+ const zoom = renderer.zoom;
15798
+ const worldLeft = -renderer.offsetX / zoom;
15799
+ const worldTop = -renderer.offsetY / zoom;
15800
+ const worldRight = worldLeft + renderer.width / zoom;
15801
+ const worldBottom = worldTop + renderer.height / zoom;
15802
+ if (this.tickBake !== null && this.tickBake.zoom === zoom && this.tickBake.pixelRatio === renderer.pixelRatio && worldLeft >= this.tickBake.worldLeft && worldRight <= this.tickBake.worldRight && worldTop >= this.tickBake.worldTop && worldBottom <= this.tickBake.worldBottom) return this.tickBake;
15803
+ const extraX = renderer.width / zoom;
15804
+ const extraY = renderer.height / zoom;
15805
+ const bakeLeft = worldLeft - extraX;
15806
+ const bakeTop = worldTop - extraY;
15807
+ const bakeRight = worldRight + extraX;
15808
+ const bakeBottom = worldBottom + extraY;
15809
+ const pr = Math.max(1, renderer.pixelRatio);
15810
+ const thickness = this.thickness;
15811
+ const step = this.getTickStep(zoom);
15812
+ const horizontal = this.tickBake?.horizontal ?? document.createElement("canvas");
15813
+ const vertical = this.tickBake?.vertical ?? document.createElement("canvas");
15814
+ const hWidth = Math.max(1, Math.ceil((bakeRight - bakeLeft) * zoom));
15815
+ const vHeight = Math.max(1, Math.ceil((bakeBottom - bakeTop) * zoom));
15816
+ horizontal.width = Math.max(1, Math.ceil(hWidth * pr));
15817
+ horizontal.height = Math.max(1, Math.ceil(thickness * pr));
15818
+ vertical.width = Math.max(1, Math.ceil(thickness * pr));
15819
+ vertical.height = Math.max(1, Math.ceil(vHeight * pr));
15820
+ const hCtx = horizontal.getContext("2d");
15821
+ const vCtx = vertical.getContext("2d");
15822
+ if (hCtx !== null) {
15823
+ hCtx.setTransform(pr, 0, 0, pr, 0, 0);
15824
+ hCtx.clearRect(0, 0, hWidth, thickness);
15825
+ hCtx.strokeStyle = this.tickColor;
15826
+ hCtx.fillStyle = this.textColor;
15827
+ hCtx.font = "10px sans-serif";
15828
+ hCtx.textAlign = "left";
15829
+ hCtx.textBaseline = "top";
15830
+ hCtx.beginPath();
15831
+ for (let x = Math.floor(bakeLeft / step) * step; x <= bakeRight; x += step) {
15832
+ const screenX = (x - bakeLeft) * zoom;
15833
+ hCtx.moveTo(screenX, thickness);
15834
+ hCtx.lineTo(screenX, thickness - 6);
15835
+ hCtx.fillText(`${Math.round(x)}`, screenX + 2, 2);
15836
+ }
15837
+ hCtx.stroke();
15838
+ }
15839
+ if (vCtx !== null) {
15840
+ vCtx.setTransform(pr, 0, 0, pr, 0, 0);
15841
+ vCtx.clearRect(0, 0, thickness, vHeight);
15842
+ vCtx.strokeStyle = this.tickColor;
15843
+ vCtx.fillStyle = this.textColor;
15844
+ vCtx.font = "10px sans-serif";
15845
+ vCtx.textAlign = "left";
15846
+ vCtx.textBaseline = "top";
15847
+ vCtx.beginPath();
15848
+ for (let y = Math.floor(bakeTop / step) * step; y <= bakeBottom; y += step) {
15849
+ const screenY = (y - bakeTop) * zoom;
15850
+ vCtx.moveTo(thickness, screenY);
15851
+ vCtx.lineTo(thickness - 6, screenY);
15852
+ vCtx.save();
15853
+ vCtx.translate(2, screenY + 2);
15854
+ vCtx.rotate(-Math.PI / 2);
15855
+ vCtx.fillText(`${Math.round(y)}`, 0, 0);
15856
+ vCtx.restore();
15857
+ }
15858
+ vCtx.stroke();
15859
+ }
15860
+ this.tickBakeGeneration += 1;
15861
+ this.tickBake = {
15862
+ zoom,
15863
+ pixelRatio: renderer.pixelRatio,
15864
+ worldLeft: bakeLeft,
15865
+ worldTop: bakeTop,
15866
+ worldRight: bakeRight,
15867
+ worldBottom: bakeBottom,
15868
+ horizontal,
15869
+ vertical
15870
+ };
15871
+ return this.tickBake;
15872
+ }
15658
15873
  getTickStep(zoom) {
15659
15874
  const rawStep = 80 / zoom;
15660
15875
  const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
@@ -15710,6 +15925,7 @@ var GuidesOverlay = class extends BaseOverlay {
15710
15925
  var MiniMap = class extends BaseOverlay {
15711
15926
  constructor(options = {}) {
15712
15927
  super(options.enabled ?? true);
15928
+ this.contentBake = null;
15713
15929
  this.options = {
15714
15930
  width: options.width ?? 180,
15715
15931
  height: options.height ?? 120,
@@ -15721,12 +15937,16 @@ var MiniMap = class extends BaseOverlay {
15721
15937
  anchor: options.anchor ?? "bottom-right"
15722
15938
  };
15723
15939
  }
15940
+ /** Cached bake projection; stable across pan if only the viewport moved. */
15941
+ getContentBakeProjectionKey() {
15942
+ return this.contentBake?.projectionKey ?? null;
15943
+ }
15724
15944
  install(renderer) {
15725
15945
  this.removeOverlay = renderer.addTopOverlayRenderer((ctx) => {
15726
15946
  if (!this.enabled) return;
15727
15947
  const layout = this.computeLayout(renderer);
15728
15948
  if (!layout) return;
15729
- const { x, y, width, height, bounds, scale, mapOffsetX, mapOffsetY, viewRect } = layout;
15949
+ const { x, y, width, height, viewRect } = layout;
15730
15950
  ctx.save();
15731
15951
  ctx.setTransform(renderer.pixelRatio, 0, 0, renderer.pixelRatio, 0, 0);
15732
15952
  ctx.setLineDash([]);
@@ -15736,42 +15956,61 @@ var MiniMap = class extends BaseOverlay {
15736
15956
  ctx.strokeStyle = this.options.borderColor;
15737
15957
  ctx.lineWidth = 1;
15738
15958
  ctx.strokeRect(x, y, width, height);
15739
- ctx.save();
15740
- ctx.translate(mapOffsetX, mapOffsetY);
15741
- ctx.scale(scale, scale);
15742
- ctx.translate(-bounds.x, -bounds.y);
15743
- ctx.setLineDash([]);
15744
- ctx.lineDashOffset = 0;
15745
- ctx.strokeStyle = "#94a3b8";
15746
- ctx.lineWidth = 1 / scale;
15747
- for (const edge of renderer.edges.values()) {
15748
- if (!edge.visible) continue;
15749
- const path = edge.path;
15750
- if (path.length < 2) continue;
15751
- ctx.beginPath();
15752
- ctx.moveTo(path[0].x, path[0].y);
15753
- for (let i = 1; i < path.length; i++) ctx.lineTo(path[i].x, path[i].y);
15754
- ctx.stroke();
15755
- }
15756
- for (const node of renderer.nodes.values()) {
15757
- if (!node.visible) continue;
15758
- const b = node.getBounds();
15759
- ctx.fillStyle = "#e2e8f0";
15760
- ctx.fillRect(b.x, b.y, b.width, b.height);
15761
- }
15762
- ctx.restore();
15763
- if (viewRect.width < 0 || viewRect.height < 0) {
15764
- ctx.restore();
15765
- return;
15959
+ const bake = this.ensureContentBake(renderer, layout);
15960
+ ctx.drawImage(bake, x, y, width, height);
15961
+ if (viewRect.width >= 0 && viewRect.height >= 0) {
15962
+ ctx.strokeStyle = this.options.viewportColor;
15963
+ ctx.lineWidth = 1.5;
15964
+ ctx.setLineDash([]);
15965
+ ctx.lineDashOffset = 0;
15966
+ ctx.strokeRect(viewRect.x, viewRect.y, viewRect.width, viewRect.height);
15766
15967
  }
15767
- ctx.strokeStyle = this.options.viewportColor;
15768
- ctx.lineWidth = 1.5;
15769
- ctx.setLineDash([]);
15770
- ctx.lineDashOffset = 0;
15771
- ctx.strokeRect(viewRect.x, viewRect.y, viewRect.width, viewRect.height);
15772
15968
  ctx.restore();
15773
15969
  });
15774
15970
  }
15971
+ ensureContentBake(renderer, layout) {
15972
+ const { x, y, width, height, bounds, scale, mapOffsetX, mapOffsetY } = layout;
15973
+ const revision = renderer.getContentRevision();
15974
+ const projectionKey = [
15975
+ width,
15976
+ height,
15977
+ Math.round(bounds.x),
15978
+ Math.round(bounds.y),
15979
+ Math.round(bounds.width),
15980
+ Math.round(bounds.height),
15981
+ scale.toFixed(5),
15982
+ Math.round(mapOffsetX),
15983
+ Math.round(mapOffsetY)
15984
+ ].join("|");
15985
+ if (this.contentBake !== null && this.contentBake.revision === revision && this.contentBake.projectionKey === projectionKey) return this.contentBake.canvas;
15986
+ const canvas = this.contentBake?.canvas ?? document.createElement("canvas");
15987
+ const pixelRatio = Math.max(1, Math.min(2, renderer.pixelRatio));
15988
+ canvas.width = Math.max(1, Math.ceil(width * pixelRatio));
15989
+ canvas.height = Math.max(1, Math.ceil(height * pixelRatio));
15990
+ const ctx = canvas.getContext("2d");
15991
+ if (ctx === null) return canvas;
15992
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
15993
+ ctx.clearRect(0, 0, width, height);
15994
+ ctx.save();
15995
+ ctx.translate(mapOffsetX - x, mapOffsetY - y);
15996
+ ctx.scale(scale, scale);
15997
+ ctx.translate(-bounds.x, -bounds.y);
15998
+ ctx.setLineDash([]);
15999
+ ctx.lineDashOffset = 0;
16000
+ for (const node of renderer.nodes.values()) {
16001
+ if (!node.visible) continue;
16002
+ const b = node.getBounds();
16003
+ ctx.fillStyle = "#e2e8f0";
16004
+ ctx.fillRect(b.x, b.y, b.width, b.height);
16005
+ }
16006
+ ctx.restore();
16007
+ this.contentBake = {
16008
+ revision,
16009
+ projectionKey,
16010
+ canvas
16011
+ };
16012
+ return canvas;
16013
+ }
15775
16014
  beginOverlayDrag(renderer, screenX, screenY) {
15776
16015
  const hit = this.hitTestMiniMap(renderer, screenX, screenY);
15777
16016
  if (!hit) return null;
@@ -15833,12 +16072,11 @@ var MiniMap = class extends BaseOverlay {
15833
16072
  computeLayout(renderer) {
15834
16073
  const rawBounds = getContentBounds({
15835
16074
  nodes: renderer.nodes.values(),
15836
- edges: renderer.edges.values(),
15837
16075
  groups: renderer.groups.values()
15838
16076
  });
15839
16077
  if (!rawBounds) return null;
15840
16078
  const margin = this.options.contentMargin;
15841
- const expandedContentBounds = {
16079
+ const bounds = {
15842
16080
  x: rawBounds.x - margin,
15843
16081
  y: rawBounds.y - margin,
15844
16082
  width: rawBounds.width + 2 * margin,
@@ -15848,17 +16086,7 @@ var MiniMap = class extends BaseOverlay {
15848
16086
  const x = anchor === "bottom-left" || anchor === "top-left" ? padding : renderer.width - width - padding;
15849
16087
  const y = anchor === "top-left" || anchor === "top-right" ? padding : renderer.height - height - padding;
15850
16088
  const viewportBounds = this.getViewportBounds(renderer);
15851
- const unionMinX = Math.min(expandedContentBounds.x, viewportBounds.x);
15852
- const unionMinY = Math.min(expandedContentBounds.y, viewportBounds.y);
15853
- const unionMaxX = Math.max(expandedContentBounds.x + expandedContentBounds.width, viewportBounds.x + viewportBounds.width);
15854
- const unionMaxY = Math.max(expandedContentBounds.y + expandedContentBounds.height, viewportBounds.y + viewportBounds.height);
15855
- const bounds = {
15856
- x: unionMinX,
15857
- y: unionMinY,
15858
- width: unionMaxX - unionMinX,
15859
- height: unionMaxY - unionMinY
15860
- };
15861
- const scale = Math.min(width / bounds.width, height / bounds.height);
16089
+ const scale = Math.min(width / Math.max(bounds.width, 1), height / Math.max(bounds.height, 1));
15862
16090
  const mapOffsetX = x + (width - bounds.width * scale) / 2;
15863
16091
  const mapOffsetY = y + (height - bounds.height * scale) / 2;
15864
16092
  let viewX = mapOffsetX + (viewportBounds.x - bounds.x) * scale;