@vanduo-oss/vd3-cbun 1.4.0 → 1.4.2

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.
@@ -128,22 +128,54 @@ function getTerrainColor(terrainType) {
128
128
  }
129
129
 
130
130
  // src/hex-grid/core.js
131
- var VD_HEX_VERSION = "1.0.1";
131
+ var VD_HEX_VERSION = "1.1.0";
132
132
  var ZOOM_MIN = 0.3;
133
133
  var ZOOM_MAX = 3;
134
134
  var ZOOM_FACTOR = 0.1;
135
135
  var DRAG_THRESHOLD = 2;
136
+ var FAST_RENDER_LIMIT = 8e3;
137
+ var SHARP_IDLE_MS = 120;
138
+ var MAX_PIXEL_RATIO = 2;
139
+ var MIN_BUCKET_SIZE = 64;
140
+ var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
136
141
  var VdHexGrid = class {
137
- constructor({ element, canvas, size = 30, width = 10, height = 10, rotation = 0 }) {
142
+ constructor({
143
+ element,
144
+ canvas,
145
+ size = 30,
146
+ width = 10,
147
+ height = 10,
148
+ rotation = 0,
149
+ pixelRatio = "auto",
150
+ cull = true
151
+ }) {
138
152
  this.element = element;
139
153
  this.canvas = canvas;
140
154
  this.size = size;
141
155
  this.width = width;
142
156
  this.height = height;
143
157
  this.rotation = rotation;
158
+ this.pixelRatio = pixelRatio;
159
+ this.cull = cull;
144
160
  this.hexes = /* @__PURE__ */ new Map();
145
161
  this.selectedHex = null;
146
162
  this.listeners = {};
163
+ this._bucketSize = Math.max(size * 2, MIN_BUCKET_SIZE);
164
+ this._index = /* @__PURE__ */ new Map();
165
+ this._gestureCancel = null;
166
+ this._sharpTimer = null;
167
+ this._frameCanvas = null;
168
+ this._frameCtx = null;
169
+ this._frameTransform = null;
170
+ this._stats = {
171
+ total: 0,
172
+ visible: 0,
173
+ drawn: 0,
174
+ mode: "sharp",
175
+ lastRenderMs: 0,
176
+ pixelRatio: 1,
177
+ scale: 1
178
+ };
147
179
  this.transform = { x: 0, y: 0, scale: 1 };
148
180
  this.dragging = false;
149
181
  this.lastPos = null;
@@ -203,6 +235,14 @@ var VdHexGrid = class {
203
235
  */
204
236
  destroy() {
205
237
  this._teardownEvents();
238
+ this._cancelGestureRender();
239
+ if (this._sharpTimer) {
240
+ clearTimeout(this._sharpTimer);
241
+ this._sharpTimer = null;
242
+ }
243
+ this._frameCanvas = null;
244
+ this._frameCtx = null;
245
+ this._frameTransform = null;
206
246
  if (this._themeObserver) {
207
247
  this._themeObserver.disconnect();
208
248
  this._themeObserver = null;
@@ -213,6 +253,198 @@ var VdHexGrid = class {
213
253
  this._themeMediaHandler = null;
214
254
  }
215
255
  }
256
+ /**
257
+ * Resolve the effective device-pixel-ratio multiplier.
258
+ * @returns {number}
259
+ */
260
+ _resolvePixelRatio() {
261
+ const requested = this.pixelRatio;
262
+ if (requested === "auto" || requested == null) {
263
+ const dpr = typeof window !== "undefined" && typeof window.devicePixelRatio === "number" ? window.devicePixelRatio : 1;
264
+ return Math.max(1, Math.min(dpr || 1, MAX_PIXEL_RATIO));
265
+ }
266
+ const n = Number(requested);
267
+ return Number.isFinite(n) && n > 0 ? n : 1;
268
+ }
269
+ /** Request an animation frame, returning a cancel function. */
270
+ _requestFrame(callback) {
271
+ if (typeof requestAnimationFrame === "function") {
272
+ const id2 = requestAnimationFrame(callback);
273
+ return () => cancelAnimationFrame(id2);
274
+ }
275
+ const id = setTimeout(callback, 16);
276
+ return () => clearTimeout(id);
277
+ }
278
+ /** Cancel a pending gesture frame, if any. */
279
+ _cancelGestureRender() {
280
+ if (this._gestureCancel) {
281
+ this._gestureCancel();
282
+ this._gestureCancel = null;
283
+ }
284
+ }
285
+ /**
286
+ * Coalesce gesture-driven transform changes into at most one render/frame.
287
+ */
288
+ _scheduleGestureRender() {
289
+ if (this._gestureCancel) return;
290
+ this._gestureCancel = this._requestFrame(() => {
291
+ this._gestureCancel = null;
292
+ this._renderGesture();
293
+ });
294
+ }
295
+ /**
296
+ * Schedule a sharp culled re-render after the gesture goes idle.
297
+ */
298
+ _scheduleSharpRender() {
299
+ if (this._sharpTimer) clearTimeout(this._sharpTimer);
300
+ this._sharpTimer = setTimeout(() => {
301
+ this._sharpTimer = null;
302
+ this._render();
303
+ }, SHARP_IDLE_MS);
304
+ }
305
+ /** Render the current gesture frame (fast blit or sharp, adaptively). */
306
+ _renderGesture() {
307
+ const visible = this.cull ? this._computeVisibleHexes() : null;
308
+ const count = visible ? visible.length : this.hexes.size;
309
+ if (count > FAST_RENDER_LIMIT && this._frameCanvas) {
310
+ this._blitFrame(visible);
311
+ } else {
312
+ this._render(visible);
313
+ }
314
+ this._scheduleSharpRender();
315
+ }
316
+ /**
317
+ * Blit the last sharp frame snapshot offset/scaled for the current transform.
318
+ * The snapshot is in screen (CSS) space at `_frameTransform`; this maps the
319
+ * pixel under the old transform to its position under the current transform.
320
+ */
321
+ _blitFrame(visible) {
322
+ const frame = this._frameCanvas;
323
+ const from = this._frameTransform;
324
+ if (!frame || !from || !frame.width || !frame.height) {
325
+ this._render();
326
+ return;
327
+ }
328
+ const rect = this.canvas.getBoundingClientRect();
329
+ const displayWidth = rect.width || 800;
330
+ const displayHeight = rect.height || 400;
331
+ const ratio = this._resolvePixelRatio();
332
+ const { x, y, scale } = this.transform;
333
+ const k = scale / (from.scale || 1);
334
+ const bx = x - k * from.x;
335
+ const by = y - k * from.y;
336
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
337
+ this.ctx.fillStyle = this.themeColors.bgPrimary;
338
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
339
+ this.ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
340
+ this.ctx.drawImage(
341
+ frame,
342
+ 0,
343
+ 0,
344
+ frame.width,
345
+ frame.height,
346
+ bx,
347
+ by,
348
+ displayWidth * k,
349
+ displayHeight * k
350
+ );
351
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
352
+ this._stats.total = this.hexes.size;
353
+ this._stats.visible = visible ? visible.length : this.hexes.size;
354
+ this._stats.drawn = 0;
355
+ this._stats.mode = "fast";
356
+ this._stats.pixelRatio = ratio;
357
+ this._stats.scale = scale;
358
+ this._stats.lastRenderMs = 0;
359
+ }
360
+ /**
361
+ * Capture the current canvas into the offscreen frame snapshot.
362
+ */
363
+ _captureFrame() {
364
+ const w = this.canvas.width;
365
+ const h2 = this.canvas.height;
366
+ if (!w || !h2) return;
367
+ if (!this._frameCanvas) {
368
+ this._frameCanvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
369
+ if (!this._frameCanvas) return;
370
+ }
371
+ const frame = this._frameCanvas;
372
+ if (frame.width !== w) frame.width = w;
373
+ if (frame.height !== h2) frame.height = h2;
374
+ const fctx = frame.getContext("2d");
375
+ if (!fctx) return;
376
+ this._frameCtx = fctx;
377
+ fctx.setTransform(1, 0, 0, 1, 0, 0);
378
+ fctx.clearRect(0, 0, w, h2);
379
+ fctx.drawImage(this.canvas, 0, 0);
380
+ this._frameTransform = { ...this.transform };
381
+ }
382
+ /**
383
+ * Compute the cells intersecting the current viewport (always culled by the
384
+ * viewport, independent of the `cull` render switch). Uses the spatial bucket
385
+ * index when present.
386
+ * @returns {HexCell[]}
387
+ */
388
+ _computeVisibleHexes() {
389
+ const rect = this.canvas.getBoundingClientRect();
390
+ const displayWidth = rect.width || 800;
391
+ const displayHeight = rect.height || 400;
392
+ const { x, y, scale } = this.transform;
393
+ const minX = -x / scale - this.size;
394
+ const maxX = (displayWidth - x) / scale + this.size;
395
+ const minY = -y / scale - this.size;
396
+ const maxY = (displayHeight - y) / scale + this.size;
397
+ if (!this._index || this._index.size === 0) return this.getAllHexes();
398
+ const bs = this._bucketSize;
399
+ const bx0 = Math.floor(minX / bs);
400
+ const bx1 = Math.floor(maxX / bs);
401
+ const by0 = Math.floor(minY / bs);
402
+ const by1 = Math.floor(maxY / bs);
403
+ const out = [];
404
+ for (let by = by0; by <= by1; by++) {
405
+ for (let bx = bx0; bx <= bx1; bx++) {
406
+ const bucket = this._index.get(`${bx},${by}`);
407
+ if (!bucket) continue;
408
+ for (let i = 0; i < bucket.length; i++) {
409
+ const hex = bucket[i];
410
+ if (hex.x >= minX && hex.x <= maxX && hex.y >= minY && hex.y <= maxY) {
411
+ out.push(hex);
412
+ }
413
+ }
414
+ }
415
+ }
416
+ return out;
417
+ }
418
+ /**
419
+ * Get the cells intersecting the current viewport, sorted by row then column.
420
+ * @returns {HexCell[]}
421
+ */
422
+ getVisibleHexes() {
423
+ return this._computeVisibleHexes().sort((a, b) => a.r - b.r || a.q - b.q);
424
+ }
425
+ /**
426
+ * Last-frame render metrics.
427
+ * @returns {{total: number, visible: number, drawn: number, mode: string, lastRenderMs: number, pixelRatio: number, scale: number}}
428
+ */
429
+ getRenderStats() {
430
+ return { ...this._stats };
431
+ }
432
+ /**
433
+ * Set the device-pixel-ratio multiplier and re-render (no grid regeneration).
434
+ * @param {number|'auto'} ratio
435
+ */
436
+ setPixelRatio(ratio) {
437
+ this.pixelRatio = ratio;
438
+ this._render();
439
+ }
440
+ /**
441
+ * Toggle viewport culling and re-render (no grid regeneration).
442
+ * @param {boolean} cull
443
+ */
444
+ setCull(cull) {
445
+ this.cull = !!cull;
446
+ this._render();
447
+ }
216
448
  /**
217
449
  * Convert screen coordinates to world coordinates
218
450
  */
@@ -240,6 +472,8 @@ var VdHexGrid = class {
240
472
  */
241
473
  _generateGrid() {
242
474
  this.hexes.clear();
475
+ this._bucketSize = Math.max(this.size * 2, MIN_BUCKET_SIZE);
476
+ this._index = /* @__PURE__ */ new Map();
243
477
  for (let r = 0; r < this.height; r++) {
244
478
  const qOffset = Math.floor(r / 2);
245
479
  for (let q = -qOffset; q < this.width - qOffset; q++) {
@@ -256,6 +490,15 @@ var VdHexGrid = class {
256
490
  data: {}
257
491
  };
258
492
  this.hexes.set(`${q},${r}`, hex);
493
+ const bx = Math.floor(hex.x / this._bucketSize);
494
+ const by = Math.floor(hex.y / this._bucketSize);
495
+ const key = `${bx},${by}`;
496
+ let bucket = this._index.get(key);
497
+ if (!bucket) {
498
+ bucket = [];
499
+ this._index.set(key, bucket);
500
+ }
501
+ bucket.push(hex);
259
502
  }
260
503
  }
261
504
  }
@@ -267,29 +510,55 @@ var VdHexGrid = class {
267
510
  this.selectedHex = this.hexes.get(`${this.selectedHex.q},${this.selectedHex.r}`) ?? null;
268
511
  }
269
512
  /**
270
- * Render the hex grid on canvas
513
+ * Render the hex grid on canvas (synchronous sharp render).
514
+ *
515
+ * @param {HexCell[]|null} [precomputedVisible] - Visible cells to draw when
516
+ * culling is on; computed from the viewport when omitted.
271
517
  */
272
- _render() {
518
+ _render(precomputedVisible) {
519
+ const started = now();
273
520
  const rect = this.canvas.getBoundingClientRect();
274
521
  const displayWidth = rect.width || 800;
275
522
  const displayHeight = rect.height || 400;
276
- this.canvas.width = displayWidth;
277
- this.canvas.height = displayHeight;
523
+ const ratio = this._resolvePixelRatio();
524
+ const bufferWidth = Math.max(1, Math.round(displayWidth * ratio));
525
+ const bufferHeight = Math.max(1, Math.round(displayHeight * ratio));
526
+ if (this.canvas.width !== bufferWidth) this.canvas.width = bufferWidth;
527
+ if (this.canvas.height !== bufferHeight) this.canvas.height = bufferHeight;
528
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
278
529
  this.ctx.fillStyle = this.themeColors.bgPrimary;
279
530
  this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
531
+ this.ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
280
532
  this.ctx.save();
281
533
  this.ctx.translate(this.transform.x, this.transform.y);
282
534
  this.ctx.scale(this.transform.scale, this.transform.scale);
283
- this.hexes.forEach((hex) => {
535
+ const visible = this.cull && this._index && this._index.size > 0 ? precomputedVisible || this._computeVisibleHexes() : null;
536
+ let drawn = 0;
537
+ const drawHex = (hex) => {
284
538
  this._drawHex(hex);
539
+ drawn += 1;
285
540
  if (this.customRenderCallback) {
286
541
  this.customRenderCallback(this.ctx, hex, this.size);
287
542
  }
288
- });
543
+ };
544
+ if (visible) {
545
+ for (let i = 0; i < visible.length; i++) drawHex(visible[i]);
546
+ } else {
547
+ this.hexes.forEach(drawHex);
548
+ }
289
549
  if (this.selectedHex) {
290
550
  this._drawHex(this.selectedHex, true);
291
551
  }
292
552
  this.ctx.restore();
553
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
554
+ this._stats.total = this.hexes.size;
555
+ this._stats.visible = visible ? visible.length : this.hexes.size;
556
+ this._stats.drawn = drawn;
557
+ this._stats.mode = "sharp";
558
+ this._stats.pixelRatio = ratio;
559
+ this._stats.scale = this.transform.scale;
560
+ this._stats.lastRenderMs = now() - started;
561
+ this._captureFrame();
293
562
  }
294
563
  /**
295
564
  * Draw a single hex
@@ -355,11 +624,20 @@ var VdHexGrid = class {
355
624
  this.transform.x += dx;
356
625
  this.transform.y += dy;
357
626
  this.lastPos = cur;
358
- this._render();
627
+ this._scheduleGestureRender();
359
628
  },
360
629
  // Pan - pointer up / leave (shared stopDrag)
361
630
  pointerup: () => {
631
+ const wasDragging = this.dragging;
362
632
  this.dragging = false;
633
+ if (wasDragging) {
634
+ this._cancelGestureRender();
635
+ if (this._sharpTimer) {
636
+ clearTimeout(this._sharpTimer);
637
+ this._sharpTimer = null;
638
+ }
639
+ this._render();
640
+ }
363
641
  if (!this.hasMoved) {
364
642
  this.canvas.style.cursor = "pointer";
365
643
  }
@@ -386,7 +664,7 @@ var VdHexGrid = class {
386
664
  this.transform.x = mouse.x - (mouse.x - this.transform.x) * scaleDiff;
387
665
  this.transform.y = mouse.y - (mouse.y - this.transform.y) * scaleDiff;
388
666
  this.transform.scale = newScale;
389
- this._render();
667
+ this._scheduleGestureRender();
390
668
  this._emit("zoom", { scale: this.transform.scale });
391
669
  },
392
670
  // Touch events for pinch-to-zoom
@@ -411,12 +689,18 @@ var VdHexGrid = class {
411
689
  this.transform.x = center.x - (center.x - this.transform.x) * scaleDiff;
412
690
  this.transform.y = center.y - (center.y - this.transform.y) * scaleDiff;
413
691
  this.transform.scale = newScale;
414
- this._render();
692
+ this._scheduleGestureRender();
415
693
  this._emit("zoom", { scale: this.transform.scale });
416
694
  }
417
695
  },
418
696
  touchend: () => {
419
697
  this.touchState.touches = [];
698
+ this._cancelGestureRender();
699
+ if (this._sharpTimer) {
700
+ clearTimeout(this._sharpTimer);
701
+ this._sharpTimer = null;
702
+ }
703
+ this._render();
420
704
  },
421
705
  // Cursor style
422
706
  mouseenter: () => {
@@ -867,7 +1151,11 @@ var VdHexGrid2 = defineComponent({
867
1151
  /** Grid rows (number of hexes). */
868
1152
  height: { type: Number, default: 10 },
869
1153
  /** Grid rotation (radians). */
870
- rotation: { type: Number, default: 0 }
1154
+ rotation: { type: Number, default: 0 },
1155
+ /** Canvas backing-store multiplier: a number or `'auto'` (default). */
1156
+ pixelRatio: { type: [Number, String], default: "auto" },
1157
+ /** Viewport culling (default true). */
1158
+ cull: { type: Boolean, default: true }
871
1159
  },
872
1160
  emits: ["select", "zoom", "pan", "ready"],
873
1161
  setup(props, { emit, expose }) {
@@ -879,7 +1167,9 @@ var VdHexGrid2 = defineComponent({
879
1167
  size: props.size,
880
1168
  width: props.width,
881
1169
  height: props.height,
882
- rotation: props.rotation
1170
+ rotation: props.rotation,
1171
+ pixelRatio: props.pixelRatio,
1172
+ cull: props.cull
883
1173
  });
884
1174
  FORWARDED_EVENTS.forEach((name) => {
885
1175
  instance.on(name, (data) => emit(name, data));
@@ -902,6 +1192,14 @@ var VdHexGrid2 = defineComponent({
902
1192
  () => props.rotation,
903
1193
  (v) => instance && instance.setRotation(v)
904
1194
  );
1195
+ watch(
1196
+ () => props.pixelRatio,
1197
+ (v) => instance && instance.setPixelRatio(v)
1198
+ );
1199
+ watch(
1200
+ () => props.cull,
1201
+ (v) => instance && instance.setCull(v)
1202
+ );
905
1203
  onBeforeUnmount(() => {
906
1204
  if (instance) {
907
1205
  instance.destroy();