@fieldnotes/core 0.65.0 → 0.66.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.
package/dist/index.js CHANGED
@@ -454,8 +454,464 @@ function sanitizeAttributes(el, tag) {
454
454
  }
455
455
  }
456
456
 
457
+ // src/fog/types.ts
458
+ var FOG_STATE_VERSION = 1;
459
+ var FOG_TILE_CELLS = 128;
460
+ var FOG_MAX_TILES = 256;
461
+
462
+ // src/fog/tile-codec.ts
463
+ var TILE_BYTES = FOG_TILE_CELLS * FOG_TILE_CELLS / 8;
464
+ var CANONICAL_B64_LENGTH = Math.ceil(TILE_BYTES / 3) * 4;
465
+ var B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
466
+ var B64_LOOKUP = new Uint8Array(128);
467
+ for (let i = 0; i < B64_CHARS.length; i++) B64_LOOKUP[B64_CHARS.charCodeAt(i)] = i;
468
+ function encodeBase64(bytes) {
469
+ let result = "";
470
+ const len = bytes.length;
471
+ for (let i = 0; i < len; i += 3) {
472
+ const a = bytes[i];
473
+ const b = i + 1 < len ? bytes[i + 1] : 0;
474
+ const c = i + 2 < len ? bytes[i + 2] : 0;
475
+ result += B64_CHARS[a >> 2 & 63];
476
+ result += B64_CHARS[(a << 4 | b >> 4) & 63];
477
+ result += i + 1 < len ? B64_CHARS[(b << 2 | c >> 6) & 63] : "=";
478
+ result += i + 2 < len ? B64_CHARS[c & 63] : "=";
479
+ }
480
+ return result;
481
+ }
482
+ function decodeBase64(str) {
483
+ if (str.length % 4 !== 0) throw new Error("Invalid base64 length");
484
+ let padCount = 0;
485
+ if (str.length >= 2 && str[str.length - 1] === "=") {
486
+ padCount++;
487
+ if (str[str.length - 2] === "=") padCount++;
488
+ }
489
+ const byteLen = str.length / 4 * 3 - padCount;
490
+ const bytes = new Uint8Array(byteLen);
491
+ let j = 0;
492
+ for (let i = 0; i < str.length; i += 4) {
493
+ const a = B64_LOOKUP[str.charCodeAt(i)];
494
+ const b = B64_LOOKUP[str.charCodeAt(i + 1)];
495
+ const c = str[i + 2] === "=" ? 0 : B64_LOOKUP[str.charCodeAt(i + 2)];
496
+ const d = str[i + 3] === "=" ? 0 : B64_LOOKUP[str.charCodeAt(i + 3)];
497
+ bytes[j++] = a << 2 | b >> 4;
498
+ if (j < byteLen) bytes[j++] = (b << 4 | c >> 2) & 255;
499
+ if (j < byteLen) bytes[j++] = (c << 6 | d) & 255;
500
+ }
501
+ return bytes;
502
+ }
503
+ function createTileBytes(fill) {
504
+ const bytes = new Uint8Array(TILE_BYTES);
505
+ if (fill) bytes.fill(255);
506
+ return bytes;
507
+ }
508
+ function setBit(bytes, col, row, value) {
509
+ const index = row * FOG_TILE_CELLS + col;
510
+ const byteIndex = index >> 3;
511
+ const bitIndex = 7 - (index & 7);
512
+ if (value) {
513
+ bytes[byteIndex] = bytes[byteIndex] | 1 << bitIndex;
514
+ } else {
515
+ bytes[byteIndex] = bytes[byteIndex] & ~(1 << bitIndex);
516
+ }
517
+ }
518
+ function isTileAllValue(bytes, value) {
519
+ const expected = value ? 255 : 0;
520
+ for (let i = 0; i < TILE_BYTES; i++) {
521
+ if (bytes[i] !== expected) return false;
522
+ }
523
+ return true;
524
+ }
525
+ function isBaseValue(base) {
526
+ return base === "revealed";
527
+ }
528
+ function isTileBase(bytes, base) {
529
+ return isTileAllValue(bytes, isBaseValue(base));
530
+ }
531
+ function canonicalizeEdgePadding(bytes, def, tileX, tileY) {
532
+ const baseVal = isBaseValue(def.base);
533
+ const worldX = tileX * FOG_TILE_CELLS * def.cellSize;
534
+ const worldY = tileY * FOG_TILE_CELLS * def.cellSize;
535
+ const boundsRight = def.bounds.x + def.bounds.w;
536
+ const boundsBottom = def.bounds.y + def.bounds.h;
537
+ for (let row = 0; row < FOG_TILE_CELLS; row++) {
538
+ for (let col = 0; col < FOG_TILE_CELLS; col++) {
539
+ const cellWorldX = worldX + col * def.cellSize;
540
+ const cellWorldY = worldY + row * def.cellSize;
541
+ const outside = cellWorldX < def.bounds.x || cellWorldY < def.bounds.y || cellWorldX >= boundsRight || cellWorldY >= boundsBottom;
542
+ if (outside) {
543
+ setBit(bytes, col, row, baseVal);
544
+ }
545
+ }
546
+ }
547
+ }
548
+ function canonicalizeFogTile(tile, def) {
549
+ const bytes = decodeBase64(tile.data);
550
+ canonicalizeEdgePadding(bytes, def, tile.x, tile.y);
551
+ if (isTileBase(bytes, def.base)) return null;
552
+ return { x: tile.x, y: tile.y, data: encodeBase64(bytes) };
553
+ }
554
+ function isCanonicalBase64(str) {
555
+ if (str.length !== CANONICAL_B64_LENGTH) return false;
556
+ if (str[str.length - 1] !== "=" || str[str.length - 2] === "=") return false;
557
+ for (let i = 0; i < str.length - 1; i++) {
558
+ if (!B64_CHARS.includes(str[i])) return false;
559
+ }
560
+ const finalSextet = B64_CHARS.indexOf(str[str.length - 2]);
561
+ return finalSextet >= 0 && (finalSextet & 3) === 0;
562
+ }
563
+ function isSafeInteger(n2) {
564
+ return typeof n2 === "number" && Number.isSafeInteger(n2);
565
+ }
566
+ function isFinitePositive(n2) {
567
+ return typeof n2 === "number" && Number.isFinite(n2) && n2 > 0;
568
+ }
569
+ function isFiniteBounds(b) {
570
+ if (typeof b !== "object" || b === null) return false;
571
+ const r = b;
572
+ return typeof r["x"] === "number" && Number.isFinite(r["x"]) && typeof r["y"] === "number" && Number.isFinite(r["y"]) && isFinitePositive(r["w"]) && isFinitePositive(r["h"]);
573
+ }
574
+ function tileIntersectsBounds(x, y, def) {
575
+ const tileWorldX = x * FOG_TILE_CELLS * def.cellSize;
576
+ const tileWorldY = y * FOG_TILE_CELLS * def.cellSize;
577
+ const tileWorldW = FOG_TILE_CELLS * def.cellSize;
578
+ const tileWorldH = FOG_TILE_CELLS * def.cellSize;
579
+ return !(tileWorldX + tileWorldW <= def.bounds.x || tileWorldY + tileWorldH <= def.bounds.y || tileWorldX >= def.bounds.x + def.bounds.w || tileWorldY >= def.bounds.y + def.bounds.h);
580
+ }
581
+ function validateFogDefinition(def) {
582
+ if (typeof def !== "object" || def === null) {
583
+ throw new Error("Invalid fog definition: expected an object");
584
+ }
585
+ const d = def;
586
+ if (d["version"] !== 1) throw new Error("Invalid fog definition: unsupported version");
587
+ if (typeof d["generation"] !== "string" || d["generation"].length === 0 || d["generation"].length > 128 || !/^[\x20-\x7e]+$/.test(d["generation"])) {
588
+ throw new Error("Invalid fog definition: invalid generation");
589
+ }
590
+ if (!isFiniteBounds(d["bounds"])) {
591
+ throw new Error("Invalid fog definition: invalid bounds");
592
+ }
593
+ if (!isFinitePositive(d["cellSize"])) {
594
+ throw new Error("Invalid fog definition: invalid cellSize");
595
+ }
596
+ if (d["tileCells"] !== 128) {
597
+ throw new Error("Invalid fog definition: tileCells must be 128");
598
+ }
599
+ if (d["base"] !== "covered" && d["base"] !== "revealed") {
600
+ throw new Error("Invalid fog definition: invalid base");
601
+ }
602
+ }
603
+ function validateFogTile(tile, def) {
604
+ if (typeof tile !== "object" || tile === null) {
605
+ throw new Error("Invalid fog tile: expected an object");
606
+ }
607
+ const t = tile;
608
+ if (!isSafeInteger(t["x"]) || !isSafeInteger(t["y"])) {
609
+ throw new Error("Invalid fog tile: coordinates must be safe integers");
610
+ }
611
+ if (!tileIntersectsBounds(t["x"], t["y"], def)) {
612
+ throw new Error("Invalid fog tile: coordinates outside bounds");
613
+ }
614
+ if (typeof t["data"] !== "string" || !isCanonicalBase64(t["data"])) {
615
+ throw new Error("Invalid fog tile: invalid data");
616
+ }
617
+ const decoded = decodeBase64(t["data"]);
618
+ if (decoded.length !== TILE_BYTES) {
619
+ throw new Error("Invalid fog tile: decoded data wrong length");
620
+ }
621
+ if (isTileBase(decoded, def.base)) {
622
+ throw new Error("Invalid fog tile: base-value tiles must be omitted");
623
+ }
624
+ const canonical = new Uint8Array(decoded);
625
+ canonicalizeEdgePadding(canonical, def, t["x"], t["y"]);
626
+ for (let i = 0; i < decoded.length; i++) {
627
+ if (decoded[i] !== canonical[i]) {
628
+ throw new Error("Invalid fog tile: non-canonical edge padding");
629
+ }
630
+ }
631
+ }
632
+ function validateFogState(state) {
633
+ if (typeof state !== "object" || state === null) {
634
+ throw new Error("Invalid fog state: expected an object");
635
+ }
636
+ const s = state;
637
+ validateFogDefinition(s["definition"]);
638
+ const def = s["definition"];
639
+ if (!Array.isArray(s["tiles"])) {
640
+ throw new Error("Invalid fog state: tiles must be an array");
641
+ }
642
+ const tiles = s["tiles"];
643
+ if (tiles.length > FOG_MAX_TILES) {
644
+ throw new Error(`Invalid fog state: too many tiles (${tiles.length} > ${FOG_MAX_TILES})`);
645
+ }
646
+ const seen = /* @__PURE__ */ new Set();
647
+ for (const tile of tiles) {
648
+ validateFogTile(tile, def);
649
+ const t = tile;
650
+ const key = `${t.x},${t.y}`;
651
+ if (seen.has(key)) {
652
+ throw new Error(`Invalid fog state: duplicate tile at (${t.x}, ${t.y})`);
653
+ }
654
+ seen.add(key);
655
+ }
656
+ }
657
+ function recommendedFogCellSize(bounds) {
658
+ let cellSize = 1;
659
+ while (true) {
660
+ const tileWorldSize = FOG_TILE_CELLS * cellSize;
661
+ const minTX = Math.floor(bounds.x / tileWorldSize);
662
+ const minTY = Math.floor(bounds.y / tileWorldSize);
663
+ const maxTX = Math.ceil((bounds.x + bounds.w) / tileWorldSize) - 1;
664
+ const maxTY = Math.ceil((bounds.y + bounds.h) / tileWorldSize) - 1;
665
+ const tw = maxTX - minTX + 1;
666
+ const th = maxTY - minTY + 1;
667
+ if (tw * th <= FOG_MAX_TILES) return cellSize;
668
+ cellSize++;
669
+ if (cellSize > 1e4) return cellSize;
670
+ }
671
+ }
672
+ function tileKey(x, y) {
673
+ return `${x},${y}`;
674
+ }
675
+ function worldToCell(worldX, worldY, cellSize) {
676
+ const cellX = Math.floor(worldX / cellSize);
677
+ const cellY = Math.floor(worldY / cellSize);
678
+ const tx = Math.floor(cellX / FOG_TILE_CELLS);
679
+ const ty = Math.floor(cellY / FOG_TILE_CELLS);
680
+ let col = cellX - tx * FOG_TILE_CELLS;
681
+ let row = cellY - ty * FOG_TILE_CELLS;
682
+ if (col < 0) col += FOG_TILE_CELLS;
683
+ if (row < 0) row += FOG_TILE_CELLS;
684
+ return { tx, ty, col, row };
685
+ }
686
+ function rasterizeRegion(state, region, operation) {
687
+ const { definition } = state;
688
+ const bitValue = operation === "reveal";
689
+ const tileMap = /* @__PURE__ */ new Map();
690
+ for (const tile of state.tiles) {
691
+ tileMap.set(tileKey(tile.x, tile.y), decodeBase64(tile.data));
692
+ }
693
+ const baseFill = isBaseValue(definition.base);
694
+ const affectedTiles = /* @__PURE__ */ new Set();
695
+ const setCellIfInBounds = (worldX, worldY) => {
696
+ if (worldX < definition.bounds.x || worldY < definition.bounds.y || worldX >= definition.bounds.x + definition.bounds.w || worldY >= definition.bounds.y + definition.bounds.h) {
697
+ return;
698
+ }
699
+ const { tx, ty, col, row } = worldToCell(worldX, worldY, definition.cellSize);
700
+ if (!tileIntersectsBounds(tx, ty, definition)) return;
701
+ const key = tileKey(tx, ty);
702
+ let bytes = tileMap.get(key);
703
+ if (!bytes) {
704
+ bytes = createTileBytes(baseFill);
705
+ tileMap.set(key, bytes);
706
+ } else if (!affectedTiles.has(key)) {
707
+ const clone = new Uint8Array(bytes);
708
+ tileMap.set(key, clone);
709
+ bytes = clone;
710
+ }
711
+ affectedTiles.add(key);
712
+ setBit(bytes, col, row, bitValue);
713
+ };
714
+ switch (region.kind) {
715
+ case "brush":
716
+ rasterizeBrush(region.points, region.radius, definition, setCellIfInBounds);
717
+ break;
718
+ case "rectangle":
719
+ rasterizeRectangle(region.from, region.to, definition, setCellIfInBounds);
720
+ break;
721
+ case "polygon":
722
+ rasterizePolygon(region.points, definition, setCellIfInBounds);
723
+ break;
724
+ }
725
+ if (affectedTiles.size === 0) return { changed: [], noop: true };
726
+ const changedTiles = [];
727
+ let hasChange = false;
728
+ for (const key of affectedTiles) {
729
+ const bytes = tileMap.get(key);
730
+ const [txStr, tyStr] = key.split(",");
731
+ const tx = Number(txStr);
732
+ const ty = Number(tyStr);
733
+ canonicalizeEdgePadding(bytes, definition, tx, ty);
734
+ const newData = encodeBase64(bytes);
735
+ const originalTile = state.tiles.find((t) => t.x === tx && t.y === ty);
736
+ if (originalTile) {
737
+ if (originalTile.data !== newData) {
738
+ hasChange = true;
739
+ changedTiles.push({ x: tx, y: ty, data: newData });
740
+ }
741
+ } else if (!isTileBase(bytes, definition.base)) {
742
+ hasChange = true;
743
+ changedTiles.push({ x: tx, y: ty, data: newData });
744
+ }
745
+ }
746
+ if (!hasChange) return { changed: [], noop: true };
747
+ return { changed: changedTiles, noop: false };
748
+ }
749
+ function applyRasterResult(state, result) {
750
+ if (result.noop) return state;
751
+ const changedMap = /* @__PURE__ */ new Map();
752
+ for (const t of result.changed) changedMap.set(tileKey(t.x, t.y), t);
753
+ const tiles = [];
754
+ const seen = /* @__PURE__ */ new Set();
755
+ for (const tile of state.tiles) {
756
+ const key = tileKey(tile.x, tile.y);
757
+ seen.add(key);
758
+ const changed = changedMap.get(key);
759
+ if (changed) {
760
+ const bytes = decodeBase64(changed.data);
761
+ if (!isTileBase(bytes, state.definition.base)) {
762
+ tiles.push(changed);
763
+ }
764
+ } else {
765
+ tiles.push(tile);
766
+ }
767
+ }
768
+ for (const t of result.changed) {
769
+ const key = tileKey(t.x, t.y);
770
+ if (seen.has(key)) continue;
771
+ const bytes = decodeBase64(t.data);
772
+ if (!isTileBase(bytes, state.definition.base)) {
773
+ tiles.push(t);
774
+ }
775
+ }
776
+ if (tiles.length > FOG_MAX_TILES) {
777
+ throw new Error(`Fog tile cap exceeded: ${tiles.length} > ${FOG_MAX_TILES}`);
778
+ }
779
+ return { definition: state.definition, tiles };
780
+ }
781
+ function sampleAndSimplify(points, tolerance) {
782
+ if (points.length <= 2) return [...points];
783
+ const sampled = [points[0]];
784
+ let lastSampled = points[0];
785
+ for (let i = 1; i < points.length - 1; i++) {
786
+ const p = points[i];
787
+ const dx = p.x - lastSampled.x;
788
+ const dy = p.y - lastSampled.y;
789
+ if (dx * dx + dy * dy >= tolerance * tolerance) {
790
+ sampled.push(p);
791
+ lastSampled = p;
792
+ }
793
+ }
794
+ sampled.push(points[points.length - 1]);
795
+ return sampled;
796
+ }
797
+ function rasterizeBrush(points, radius, def, setCell) {
798
+ if (points.length === 0) return;
799
+ const simplified = sampleAndSimplify(points, def.cellSize * 0.5);
800
+ for (let i = 0; i < simplified.length; i++) {
801
+ const p = simplified[i];
802
+ rasterizeDisc(p.x, p.y, radius, def, setCell);
803
+ if (i > 0) {
804
+ const prev = simplified[i - 1];
805
+ rasterizeCapsuleSegment(prev.x, prev.y, p.x, p.y, radius, def, setCell);
806
+ }
807
+ }
808
+ }
809
+ function rasterizeDisc(cx, cy, radius, def, setCell) {
810
+ const minX = Math.max(def.bounds.x, cx - radius);
811
+ const maxX = Math.min(def.bounds.x + def.bounds.w - 1, cx + radius);
812
+ const minY = Math.max(def.bounds.y, cy - radius);
813
+ const maxY = Math.min(def.bounds.y + def.bounds.h - 1, cy + radius);
814
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
815
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
816
+ const r2 = radius * radius;
817
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
818
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
819
+ const cellCenterX = wx + def.cellSize * 0.5;
820
+ const cellCenterY = wy + def.cellSize * 0.5;
821
+ const dx = cellCenterX - cx;
822
+ const dy = cellCenterY - cy;
823
+ if (dx * dx + dy * dy <= r2) {
824
+ setCell(wx, wy);
825
+ }
826
+ }
827
+ }
828
+ }
829
+ function rasterizeCapsuleSegment(x1, y1, x2, y2, radius, def, setCell) {
830
+ const segDx = x2 - x1;
831
+ const segDy = y2 - y1;
832
+ const segLen2 = segDx * segDx + segDy * segDy;
833
+ if (segLen2 === 0) return;
834
+ const minX = Math.max(def.bounds.x, Math.min(x1, x2) - radius);
835
+ const maxX = Math.min(def.bounds.x + def.bounds.w - 1, Math.max(x1, x2) + radius);
836
+ const minY = Math.max(def.bounds.y, Math.min(y1, y2) - radius);
837
+ const maxY = Math.min(def.bounds.y + def.bounds.h - 1, Math.max(y1, y2) + radius);
838
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
839
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
840
+ const r2 = radius * radius;
841
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
842
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
843
+ const cellCenterX = wx + def.cellSize * 0.5;
844
+ const cellCenterY = wy + def.cellSize * 0.5;
845
+ const t = Math.max(
846
+ 0,
847
+ Math.min(1, ((cellCenterX - x1) * segDx + (cellCenterY - y1) * segDy) / segLen2)
848
+ );
849
+ const projX = x1 + t * segDx;
850
+ const projY = y1 + t * segDy;
851
+ const dx = cellCenterX - projX;
852
+ const dy = cellCenterY - projY;
853
+ if (dx * dx + dy * dy <= r2) {
854
+ setCell(wx, wy);
855
+ }
856
+ }
857
+ }
858
+ }
859
+ function rasterizeRectangle(from, to, def, setCell) {
860
+ const minX = Math.max(def.bounds.x, Math.min(from.x, to.x));
861
+ const maxX = Math.min(def.bounds.x + def.bounds.w - 1, Math.max(from.x, to.x));
862
+ const minY = Math.max(def.bounds.y, Math.min(from.y, to.y));
863
+ const maxY = Math.min(def.bounds.y + def.bounds.h - 1, Math.max(from.y, to.y));
864
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
865
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
866
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
867
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
868
+ setCell(wx, wy);
869
+ }
870
+ }
871
+ }
872
+ function rasterizePolygon(points, def, setCell) {
873
+ if (points.length < 3) return;
874
+ let minX = Infinity;
875
+ let maxX = -Infinity;
876
+ let minY = Infinity;
877
+ let maxY = -Infinity;
878
+ for (const p of points) {
879
+ if (p.x < minX) minX = p.x;
880
+ if (p.x > maxX) maxX = p.x;
881
+ if (p.y < minY) minY = p.y;
882
+ if (p.y > maxY) maxY = p.y;
883
+ }
884
+ minX = Math.max(def.bounds.x, minX);
885
+ maxX = Math.min(def.bounds.x + def.bounds.w - 1, maxX);
886
+ minY = Math.max(def.bounds.y, minY);
887
+ maxY = Math.min(def.bounds.y + def.bounds.h - 1, maxY);
888
+ const startCol = Math.floor(minX / def.cellSize) * def.cellSize;
889
+ const startRow = Math.floor(minY / def.cellSize) * def.cellSize;
890
+ for (let wy = startRow; wy <= maxY; wy += def.cellSize) {
891
+ for (let wx = startCol; wx <= maxX; wx += def.cellSize) {
892
+ const cx = wx + def.cellSize * 0.5;
893
+ const cy = wy + def.cellSize * 0.5;
894
+ if (pointInPolygon(cx, cy, points)) {
895
+ setCell(wx, wy);
896
+ }
897
+ }
898
+ }
899
+ }
900
+ function pointInPolygon(x, y, polygon) {
901
+ let inside = false;
902
+ const n2 = polygon.length;
903
+ for (let i = 0, j = n2 - 1; i < n2; j = i++) {
904
+ const pi = polygon[i];
905
+ const pj = polygon[j];
906
+ if (pi.y > y !== pj.y > y && x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x) {
907
+ inside = !inside;
908
+ }
909
+ }
910
+ return inside;
911
+ }
912
+
457
913
  // src/core/state-serializer.ts
458
- var CURRENT_VERSION = 2;
914
+ var CURRENT_VERSION = 3;
459
915
  var ELEMENT_TYPES = [
460
916
  "stroke",
461
917
  "note",
@@ -467,7 +923,7 @@ var ELEMENT_TYPES = [
467
923
  "grid",
468
924
  "template"
469
925
  ];
470
- function exportState(elements, camera, layers = [], activeLayerId) {
926
+ function exportState(elements, camera, layers = [], activeLayerId, fog) {
471
927
  const state = {
472
928
  version: CURRENT_VERSION,
473
929
  camera: {
@@ -484,6 +940,7 @@ function exportState(elements, camera, layers = [], activeLayerId) {
484
940
  layers: layers.map((l) => ({ ...l }))
485
941
  };
486
942
  if (activeLayerId) state.activeLayerId = activeLayerId;
943
+ if (fog) state.fog = structuredClone(fog);
487
944
  return state;
488
945
  }
489
946
  function parseState(json) {
@@ -561,6 +1018,9 @@ function validateState(data) {
561
1018
  }
562
1019
  }
563
1020
  cleanBindings(elements);
1021
+ if (obj["fog"] !== void 0 && obj["fog"] !== null) {
1022
+ validateFogState(obj["fog"]);
1023
+ }
564
1024
  }
565
1025
  function validateElement(el) {
566
1026
  if (!isRecord(el)) {
@@ -706,12 +1166,14 @@ var AutoSave = class {
706
1166
  this.key = options.key ?? DEFAULT_KEY;
707
1167
  this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
708
1168
  this.layerManager = options.layerManager;
1169
+ this.fogManager = options.fogManager;
709
1170
  this.adapter = options.adapter ?? new LocalStorageAdapter();
710
1171
  this.onError = options.onError;
711
1172
  }
712
1173
  key;
713
1174
  debounceMs;
714
1175
  layerManager;
1176
+ fogManager;
715
1177
  adapter;
716
1178
  timerId = null;
717
1179
  unsubscribers = [];
@@ -729,6 +1191,9 @@ var AutoSave = class {
729
1191
  if (this.layerManager) {
730
1192
  this.unsubscribers.push(this.layerManager.on("change", schedule));
731
1193
  }
1194
+ if (this.fogManager) {
1195
+ this.unsubscribers.push(this.fogManager.on("change", schedule));
1196
+ }
732
1197
  }
733
1198
  stop() {
734
1199
  this.cancelPending();
@@ -765,7 +1230,8 @@ var AutoSave = class {
765
1230
  this.saving = true;
766
1231
  try {
767
1232
  const layers = this.layerManager?.snapshot() ?? [];
768
- const state = exportState(this.store.snapshot(), this.camera, layers);
1233
+ const fog = this.fogManager?.getState() ?? void 0;
1234
+ const state = exportState(this.store.snapshot(), this.camera, layers, void 0, fog);
769
1235
  await this.adapter.save(this.key, JSON.stringify(state));
770
1236
  } catch (e) {
771
1237
  this.onError?.(e instanceof Error ? e : new Error(String(e)));
@@ -5097,6 +5563,8 @@ var MinimapController = class {
5097
5563
  // read the `viewport` parameter. Assigned in the constructor body instead.
5098
5564
  htmlPainters;
5099
5565
  scene = null;
5566
+ fogRenderer = null;
5567
+ fogUnsub = null;
5100
5568
  frameId = null;
5101
5569
  debounceTimer = null;
5102
5570
  dragging = false;
@@ -5111,6 +5579,15 @@ var MinimapController = class {
5111
5579
  this.renderScene();
5112
5580
  this.requestDraw();
5113
5581
  }
5582
+ setFogRenderer(renderer) {
5583
+ if (this.disposed) return;
5584
+ if (this.fogUnsub) {
5585
+ this.fogUnsub();
5586
+ this.fogUnsub = null;
5587
+ }
5588
+ this.fogRenderer = renderer;
5589
+ this.invalidateScene();
5590
+ }
5114
5591
  requestDraw() {
5115
5592
  if (this.disposed || this.frameId !== null) return;
5116
5593
  this.frameId = this.requestFrame(this.draw);
@@ -5172,8 +5649,15 @@ var MinimapController = class {
5172
5649
  }
5173
5650
  currentMapping() {
5174
5651
  const viewportRect = this.viewport.getVisibleRect();
5175
- const content = getElementsBoundingBox(this.sceneElements());
5176
- return content ? unionBounds(content, viewportRect) : viewportRect;
5652
+ let mapping = getElementsBoundingBox(this.sceneElements());
5653
+ mapping = mapping ? unionBounds(mapping, viewportRect) : viewportRect;
5654
+ if (this.fogRenderer?.isVisible()) {
5655
+ const fogState = this.fogRenderer.getState();
5656
+ if (fogState) {
5657
+ mapping = unionBounds(mapping, fogState.definition.bounds);
5658
+ }
5659
+ }
5660
+ return mapping;
5177
5661
  }
5178
5662
  onViewChanged() {
5179
5663
  if (this.disposed) return;
@@ -5231,6 +5715,23 @@ var MinimapController = class {
5231
5715
  ctx.drawImage(layerCanvas, 0, 0);
5232
5716
  ctx.restore();
5233
5717
  }
5718
+ if (this.fogRenderer?.isVisible()) {
5719
+ const fogState = this.fogRenderer.getState();
5720
+ const fogMode = this.fogRenderer.getViewMode();
5721
+ if (fogState && (fogMode === "editor" || fogMode === "player")) {
5722
+ ctx.save();
5723
+ ctx.setTransform(
5724
+ dpr * transform.scale,
5725
+ 0,
5726
+ 0,
5727
+ dpr * transform.scale,
5728
+ dpr * transform.offsetX,
5729
+ dpr * transform.offsetY
5730
+ );
5731
+ this.fogRenderer.renderForExport(ctx, fogState, fogMode);
5732
+ ctx.restore();
5733
+ }
5734
+ }
5234
5735
  this.scene = { canvas: sceneCanvas, transform, mapping };
5235
5736
  }
5236
5737
  renderLayerElements(ctx, elements, t, dpr) {
@@ -5343,9 +5844,15 @@ var Minimap = class {
5343
5844
  this.canvas = canvas;
5344
5845
  this.controller = new MinimapController(viewport, canvas, { width: WIDTH, height: HEIGHT });
5345
5846
  }
5847
+ setFogRenderer(renderer) {
5848
+ this.controller.setFogRenderer(renderer);
5849
+ }
5346
5850
  scheduleDraw() {
5347
5851
  this.controller.requestDraw();
5348
5852
  }
5853
+ invalidateScene() {
5854
+ this.controller.invalidateScene();
5855
+ }
5349
5856
  destroy() {
5350
5857
  this.controller.dispose();
5351
5858
  this.canvas.remove();
@@ -6667,6 +7174,220 @@ async function renderHtmlElements(elements, options) {
6667
7174
  return sources;
6668
7175
  }
6669
7176
 
7177
+ // src/fog/fog-renderer.ts
7178
+ var DEFAULT_EDITOR_COLOR = "rgba(30, 40, 60, 0.45)";
7179
+ var DEFAULT_PLAYER_COLOR = "#0b1020";
7180
+ var FogRenderer = class {
7181
+ tileCache = /* @__PURE__ */ new Map();
7182
+ state = null;
7183
+ viewMode = "off";
7184
+ dirty = true;
7185
+ editorColor;
7186
+ playerColor;
7187
+ constructor(options = {}) {
7188
+ this.editorColor = options.editorColor ?? DEFAULT_EDITOR_COLOR;
7189
+ this.playerColor = options.playerColor ?? DEFAULT_PLAYER_COLOR;
7190
+ }
7191
+ setState(state) {
7192
+ this.state = state;
7193
+ this.dirty = true;
7194
+ }
7195
+ setViewMode(mode) {
7196
+ if (mode === this.viewMode) return;
7197
+ this.viewMode = mode;
7198
+ this.dirty = true;
7199
+ }
7200
+ getState() {
7201
+ return this.state;
7202
+ }
7203
+ getViewMode() {
7204
+ return this.viewMode;
7205
+ }
7206
+ markDirty() {
7207
+ this.dirty = true;
7208
+ }
7209
+ isDirty() {
7210
+ return this.dirty;
7211
+ }
7212
+ isVisible() {
7213
+ return this.viewMode !== "off" && this.state !== null;
7214
+ }
7215
+ render(ctx, camera, viewportWidth, viewportHeight, _dpr) {
7216
+ if (!this.state || this.viewMode === "off") return;
7217
+ const def = this.state.definition;
7218
+ const cellSize = def.cellSize;
7219
+ const tileWorldSize = FOG_TILE_CELLS * cellSize;
7220
+ const color = this.viewMode === "editor" ? this.editorColor : this.playerColor;
7221
+ const worldBounds = getVisibleWorld(camera, viewportWidth, viewportHeight);
7222
+ const minTX = Math.floor(Math.max(def.bounds.x, worldBounds.x) / tileWorldSize);
7223
+ const minTY = Math.floor(Math.max(def.bounds.y, worldBounds.y) / tileWorldSize);
7224
+ const maxTX = Math.floor(
7225
+ Math.min(def.bounds.x + def.bounds.w - 1, worldBounds.x + worldBounds.w) / tileWorldSize
7226
+ );
7227
+ const maxTY = Math.floor(
7228
+ Math.min(def.bounds.y + def.bounds.h - 1, worldBounds.y + worldBounds.h) / tileWorldSize
7229
+ );
7230
+ ctx.save();
7231
+ ctx.translate(camera.position.x, camera.position.y);
7232
+ ctx.scale(camera.zoom, camera.zoom);
7233
+ const tileMap = /* @__PURE__ */ new Map();
7234
+ for (const tile of this.state.tiles) {
7235
+ tileMap.set(`${tile.x},${tile.y}`, tile.data);
7236
+ }
7237
+ const baseCovered = def.base === "covered";
7238
+ for (let ty = minTY; ty <= maxTY; ty++) {
7239
+ for (let tx = minTX; tx <= maxTX; tx++) {
7240
+ const key = `${tx},${ty}`;
7241
+ const data = tileMap.get(key);
7242
+ const tileWorldX = tx * tileWorldSize;
7243
+ const tileWorldY = ty * tileWorldSize;
7244
+ if (!data && baseCovered) {
7245
+ ctx.fillStyle = color;
7246
+ const clipX = Math.max(tileWorldX, def.bounds.x);
7247
+ const clipY = Math.max(tileWorldY, def.bounds.y);
7248
+ const clipR = Math.min(tileWorldX + tileWorldSize, def.bounds.x + def.bounds.w);
7249
+ const clipB = Math.min(tileWorldY + tileWorldSize, def.bounds.y + def.bounds.h);
7250
+ if (clipR > clipX && clipB > clipY) {
7251
+ ctx.fillRect(clipX, clipY, clipR - clipX, clipB - clipY);
7252
+ }
7253
+ continue;
7254
+ }
7255
+ if (!data && !baseCovered) {
7256
+ continue;
7257
+ }
7258
+ if (data) {
7259
+ this.renderTile(ctx, data, tx, ty, def, color);
7260
+ }
7261
+ }
7262
+ }
7263
+ ctx.restore();
7264
+ this.dirty = false;
7265
+ }
7266
+ renderForExport(ctx, state, mode, color) {
7267
+ const def = state.definition;
7268
+ const cellSize = def.cellSize;
7269
+ const tileWorldSize = FOG_TILE_CELLS * cellSize;
7270
+ const fogColor = color ?? (mode === "editor" ? this.editorColor : this.playerColor);
7271
+ const baseCovered = def.base === "covered";
7272
+ const tileMap = /* @__PURE__ */ new Map();
7273
+ for (const tile of state.tiles) {
7274
+ tileMap.set(`${tile.x},${tile.y}`, tile.data);
7275
+ }
7276
+ const minTX = Math.floor(def.bounds.x / tileWorldSize);
7277
+ const minTY = Math.floor(def.bounds.y / tileWorldSize);
7278
+ const maxTX = Math.floor((def.bounds.x + def.bounds.w - 1) / tileWorldSize);
7279
+ const maxTY = Math.floor((def.bounds.y + def.bounds.h - 1) / tileWorldSize);
7280
+ for (let ty = minTY; ty <= maxTY; ty++) {
7281
+ for (let tx = minTX; tx <= maxTX; tx++) {
7282
+ const key = `${tx},${ty}`;
7283
+ const data = tileMap.get(key);
7284
+ const tileWorldX = tx * tileWorldSize;
7285
+ const tileWorldY = ty * tileWorldSize;
7286
+ if (!data && baseCovered) {
7287
+ ctx.fillStyle = fogColor;
7288
+ const clipX = Math.max(tileWorldX, def.bounds.x);
7289
+ const clipY = Math.max(tileWorldY, def.bounds.y);
7290
+ const clipR = Math.min(tileWorldX + tileWorldSize, def.bounds.x + def.bounds.w);
7291
+ const clipB = Math.min(tileWorldY + tileWorldSize, def.bounds.y + def.bounds.h);
7292
+ if (clipR > clipX && clipB > clipY) {
7293
+ ctx.fillRect(clipX, clipY, clipR - clipX, clipB - clipY);
7294
+ }
7295
+ continue;
7296
+ }
7297
+ if (data) {
7298
+ this.renderTileForExport(ctx, data, tx, ty, def, fogColor);
7299
+ }
7300
+ }
7301
+ }
7302
+ }
7303
+ dispose() {
7304
+ this.tileCache.clear();
7305
+ this.state = null;
7306
+ }
7307
+ tileRaster(data, color) {
7308
+ const key = `${color}\0${data}`;
7309
+ const cached = this.tileCache.get(key);
7310
+ if (cached) return cached;
7311
+ if (typeof document === "undefined") return null;
7312
+ const canvas = document.createElement("canvas");
7313
+ canvas.width = FOG_TILE_CELLS;
7314
+ canvas.height = FOG_TILE_CELLS;
7315
+ const ctx = canvas.getContext("2d");
7316
+ if (!ctx) return null;
7317
+ const bytes = decodeBase64(data);
7318
+ ctx.fillStyle = color;
7319
+ for (let row = 0; row < FOG_TILE_CELLS; row++) {
7320
+ for (let col = 0; col < FOG_TILE_CELLS; col++) {
7321
+ const index = row * FOG_TILE_CELLS + col;
7322
+ const byteIndex = index >> 3;
7323
+ const bitIndex = 7 - (index & 7);
7324
+ const revealed = (bytes[byteIndex] >> bitIndex & 1) === 1;
7325
+ if (!revealed) ctx.fillRect(col, row, 1, 1);
7326
+ }
7327
+ }
7328
+ if (this.tileCache.size >= 256) {
7329
+ const oldest = this.tileCache.keys().next().value;
7330
+ if (oldest !== void 0) this.tileCache.delete(oldest);
7331
+ }
7332
+ this.tileCache.set(key, canvas);
7333
+ return canvas;
7334
+ }
7335
+ renderTile(ctx, data, tx, ty, def, color) {
7336
+ const cellSize = def.cellSize;
7337
+ const tileWorldX = tx * FOG_TILE_CELLS * cellSize;
7338
+ const tileWorldY = ty * FOG_TILE_CELLS * cellSize;
7339
+ const raster = this.tileRaster(data, color);
7340
+ if (raster) {
7341
+ ctx.save();
7342
+ ctx.beginPath();
7343
+ ctx.rect(def.bounds.x, def.bounds.y, def.bounds.w, def.bounds.h);
7344
+ ctx.clip();
7345
+ ctx.imageSmoothingEnabled = false;
7346
+ ctx.drawImage(
7347
+ raster,
7348
+ tileWorldX,
7349
+ tileWorldY,
7350
+ FOG_TILE_CELLS * cellSize,
7351
+ FOG_TILE_CELLS * cellSize
7352
+ );
7353
+ ctx.restore();
7354
+ return;
7355
+ }
7356
+ const bytes = decodeBase64(data);
7357
+ ctx.fillStyle = color;
7358
+ for (let row = 0; row < FOG_TILE_CELLS; row++) {
7359
+ for (let col = 0; col < FOG_TILE_CELLS; col++) {
7360
+ const cellWorldX = tileWorldX + col * cellSize;
7361
+ const cellWorldY = tileWorldY + row * cellSize;
7362
+ if (cellWorldX < def.bounds.x || cellWorldY < def.bounds.y || cellWorldX >= def.bounds.x + def.bounds.w || cellWorldY >= def.bounds.y + def.bounds.h) {
7363
+ continue;
7364
+ }
7365
+ const index = row * FOG_TILE_CELLS + col;
7366
+ const byteIndex = index >> 3;
7367
+ const bitIndex = 7 - (index & 7);
7368
+ const revealed = (bytes[byteIndex] >> bitIndex & 1) === 1;
7369
+ const covered = !revealed;
7370
+ if (covered) {
7371
+ ctx.fillRect(cellWorldX, cellWorldY, cellSize, cellSize);
7372
+ }
7373
+ }
7374
+ }
7375
+ }
7376
+ renderTileForExport(ctx, data, tx, ty, def, color) {
7377
+ this.renderTile(ctx, data, tx, ty, def, color);
7378
+ }
7379
+ };
7380
+ function getVisibleWorld(camera, viewportWidth, viewportHeight) {
7381
+ const topLeft = camera.screenToWorld({ x: 0, y: 0 });
7382
+ const bottomRight = camera.screenToWorld({ x: viewportWidth, y: viewportHeight });
7383
+ return {
7384
+ x: topLeft.x,
7385
+ y: topLeft.y,
7386
+ w: bottomRight.x - topLeft.x,
7387
+ h: bottomRight.y - topLeft.y
7388
+ };
7389
+ }
7390
+
6670
7391
  // src/canvas/export-image.ts
6671
7392
  var DEFAULT_IMAGE_TIMEOUT_MS = 1e4;
6672
7393
  var DEFAULT_MAX_DIMENSION = 16384;
@@ -7079,6 +7800,10 @@ async function exportImage(store, options = {}, layerManager) {
7079
7800
  renderGridForBounds(ctx, grid, bounds);
7080
7801
  ctx.restore();
7081
7802
  }
7803
+ if (options.fog) {
7804
+ const fogRenderer = new FogRenderer();
7805
+ fogRenderer.renderForExport(ctx, options.fog.state, options.fog.mode, options.fog.color);
7806
+ }
7082
7807
  const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
7083
7808
  return new Promise((resolve) => {
7084
7809
  canvas.toBlob((blob) => resolve(blob), mimeType, options.quality);
@@ -7460,6 +8185,27 @@ async function exportSvg(store, options = {}, layerManager) {
7460
8185
  const opacity = layerManager?.getLayer?.(grid.layerId)?.opacity ?? 1;
7461
8186
  body += opacity === 1 ? emitted : `<g opacity="${n(opacity)}">${emitted}</g>`;
7462
8187
  }
8188
+ if (options.fog && typeof document !== "undefined") {
8189
+ const fogState = options.fog.state;
8190
+ const fogW = Math.max(1, Math.ceil(bounds.w));
8191
+ const fogH = Math.max(1, Math.ceil(bounds.h));
8192
+ const fogCanvas = document.createElement("canvas");
8193
+ fogCanvas.width = fogW;
8194
+ fogCanvas.height = fogH;
8195
+ const fogCtx = fogCanvas.getContext("2d");
8196
+ if (fogCtx) {
8197
+ fogCtx.translate(-bounds.x, -bounds.y);
8198
+ const fogRenderer = new FogRenderer();
8199
+ fogRenderer.renderForExport(fogCtx, fogState, options.fog.mode, options.fog.color);
8200
+ try {
8201
+ const fogDataUri = fogCanvas.toDataURL("image/png");
8202
+ if (fogDataUri.startsWith("data:")) {
8203
+ body += `<image href="${esc(fogDataUri)}" x="${n(bounds.x)}" y="${n(bounds.y)}" width="${n(bounds.w)}" height="${n(bounds.h)}" />`;
8204
+ }
8205
+ } catch {
8206
+ }
8207
+ }
8208
+ }
7463
8209
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(bounds.w)}" height="${n(bounds.h)}" viewBox="${n(bounds.x)} ${n(bounds.y)} ${n(bounds.w)} ${n(bounds.h)}">${body}</svg>`;
7464
8210
  }
7465
8211
  function emitElement(el, imageDataUris, htmlDataUris, rasterScale, firstGrid, store, resourceOptions) {
@@ -8297,6 +9043,7 @@ var RenderLoop = class {
8297
9043
  layerCache;
8298
9044
  marginViewport;
8299
9045
  hybridSurface;
9046
+ fogRenderer;
8300
9047
  activeDrawingLayerId = null;
8301
9048
  gridCacheDirty = true;
8302
9049
  // set on recenter/viewport-change; consumed by the grid block
@@ -8320,6 +9067,7 @@ var RenderLoop = class {
8320
9067
  this.layerCache = deps.layerCache;
8321
9068
  this.marginViewport = deps.marginViewport;
8322
9069
  this.hybridSurface = deps.hybridSurface;
9070
+ this.fogRenderer = deps.fogRenderer;
8323
9071
  }
8324
9072
  requestRender() {
8325
9073
  this.needsRender = true;
@@ -8629,9 +9377,12 @@ var RenderLoop = class {
8629
9377
  group.push(element);
8630
9378
  }
8631
9379
  const activeTool = this.toolManager.activeTool;
8632
- const overlayOrder = visibleElements.length + 1;
9380
+ const fogVisible = this.fogRenderer?.isVisible() ?? false;
9381
+ const fogOrder = visibleElements.length + 1;
9382
+ const overlayOrder = fogVisible ? fogOrder + 1 : visibleElements.length + 1;
8633
9383
  const hasOverlay = activeTool?.renderOverlay !== void 0 || this.overlays.size > 0;
8634
- if (hybridActive && hasOverlay) hybridOrders.add(overlayOrder);
9384
+ if (fogVisible) hybridOrders.add(fogOrder);
9385
+ if (hasOverlay && (hybridActive || fogVisible)) hybridOrders.add(overlayOrder);
8635
9386
  this.hybridSurface.beginFrame(hybridOrders, this.canvasEl.width, this.canvasEl.height);
8636
9387
  for (const [layerId, elements] of this.layerGroups) {
8637
9388
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
@@ -8745,8 +9496,18 @@ var RenderLoop = class {
8745
9496
  }
8746
9497
  hybridCtx.restore();
8747
9498
  }
9499
+ if (fogVisible && this.fogRenderer) {
9500
+ const fogCtx = this.hybridSurface.getContext(fogOrder);
9501
+ if (fogCtx) {
9502
+ fogCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
9503
+ fogCtx.save();
9504
+ fogCtx.scale(dpr, dpr);
9505
+ this.fogRenderer.render(fogCtx, this.camera, cssWidth, cssHeight, dpr);
9506
+ fogCtx.restore();
9507
+ }
9508
+ }
8748
9509
  const overlayT0 = performance.now();
8749
- if (hybridActive && hasOverlay) {
9510
+ if ((hybridActive || fogVisible) && hasOverlay) {
8750
9511
  const overlayCtx = this.hybridSurface.getContext(overlayOrder);
8751
9512
  if (overlayCtx) {
8752
9513
  overlayCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
@@ -9757,6 +10518,248 @@ var ElementActivation = class {
9757
10518
  }
9758
10519
  };
9759
10520
 
10521
+ // src/fog/fog-command.ts
10522
+ var FogRegionCommand = class {
10523
+ constructor(manager, before, after) {
10524
+ this.manager = manager;
10525
+ this.before = before;
10526
+ this.after = after;
10527
+ }
10528
+ execute(_store) {
10529
+ this.manager.applyTilesDirect(this.after);
10530
+ }
10531
+ undo(_store) {
10532
+ this.manager.applyTilesDirect(this.before);
10533
+ }
10534
+ };
10535
+ var FogResetCommand = class {
10536
+ constructor(manager, before, after) {
10537
+ this.manager = manager;
10538
+ this.before = before;
10539
+ this.after = after;
10540
+ }
10541
+ execute(_store) {
10542
+ this.manager.restoreHistoryState(this.after);
10543
+ }
10544
+ undo(_store) {
10545
+ this.manager.restoreHistoryState(this.before);
10546
+ }
10547
+ };
10548
+
10549
+ // src/fog/fog-manager.ts
10550
+ function defaultIdFactory() {
10551
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
10552
+ return crypto.randomUUID();
10553
+ }
10554
+ return `fog-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
10555
+ }
10556
+ var FogManager = class {
10557
+ state = null;
10558
+ viewMode = "off";
10559
+ idFactory;
10560
+ onCommand;
10561
+ changeListeners = /* @__PURE__ */ new Set();
10562
+ viewListeners = /* @__PURE__ */ new Set();
10563
+ constructor(options = {}) {
10564
+ this.idFactory = options.idFactory ?? defaultIdFactory;
10565
+ this.onCommand = options.onCommand;
10566
+ }
10567
+ getState() {
10568
+ if (!this.state) return null;
10569
+ return {
10570
+ definition: { ...this.state.definition, bounds: { ...this.state.definition.bounds } },
10571
+ tiles: this.state.tiles.map((t) => ({ ...t }))
10572
+ };
10573
+ }
10574
+ getViewMode() {
10575
+ return this.viewMode;
10576
+ }
10577
+ initialize(options) {
10578
+ const base = options.base ?? "covered";
10579
+ const cellSize = options.cellSize ?? recommendedFogCellSize(options.bounds);
10580
+ const generation = this.idFactory();
10581
+ const newState = {
10582
+ definition: {
10583
+ version: 1,
10584
+ generation,
10585
+ bounds: { ...options.bounds },
10586
+ cellSize,
10587
+ tileCells: FOG_TILE_CELLS,
10588
+ base
10589
+ },
10590
+ tiles: []
10591
+ };
10592
+ validateFogState(newState);
10593
+ const before = this.state;
10594
+ this.state = newState;
10595
+ const command = new FogResetCommand(this, before, newState);
10596
+ this.onCommand?.(command);
10597
+ this.notifyChange({ kind: "definition" });
10598
+ return structuredClone(newState);
10599
+ }
10600
+ loadState(state, meta) {
10601
+ if (state !== null) {
10602
+ validateFogState(state);
10603
+ this.state = structuredClone(state);
10604
+ } else {
10605
+ this.state = null;
10606
+ }
10607
+ this.notifyChange({
10608
+ kind: state === null ? "disable" : "definition",
10609
+ origin: meta?.origin
10610
+ });
10611
+ }
10612
+ /** Restores a historical visual state without reusing its causal generation id. */
10613
+ restoreHistoryState(state) {
10614
+ if (state === null) {
10615
+ this.loadState(null);
10616
+ return;
10617
+ }
10618
+ this.loadState({
10619
+ definition: { ...state.definition, generation: this.idFactory() },
10620
+ tiles: state.tiles
10621
+ });
10622
+ }
10623
+ setBounds(bounds) {
10624
+ if (!this.state) return;
10625
+ const def = this.state.definition;
10626
+ validateFogState({
10627
+ definition: { ...def, bounds: { ...bounds } },
10628
+ tiles: []
10629
+ });
10630
+ const shrinks = bounds.x > def.bounds.x || bounds.y > def.bounds.y || bounds.x + bounds.w < def.bounds.x + def.bounds.w || bounds.y + bounds.h < def.bounds.y + def.bounds.h;
10631
+ const nextDefinition = {
10632
+ ...def,
10633
+ bounds: { ...bounds },
10634
+ generation: shrinks ? this.idFactory() : def.generation
10635
+ };
10636
+ const tiles = this.state.tiles.flatMap((tile) => {
10637
+ const tileWorldX = tile.x * FOG_TILE_CELLS * def.cellSize;
10638
+ const tileWorldY = tile.y * FOG_TILE_CELLS * def.cellSize;
10639
+ const tileWorldW = FOG_TILE_CELLS * def.cellSize;
10640
+ const tileWorldH = FOG_TILE_CELLS * def.cellSize;
10641
+ const intersects2 = !(tileWorldX + tileWorldW <= bounds.x || tileWorldY + tileWorldH <= bounds.y || tileWorldX >= bounds.x + bounds.w || tileWorldY >= bounds.y + bounds.h);
10642
+ if (!intersects2) return [];
10643
+ const canonical = canonicalizeFogTile(tile, nextDefinition);
10644
+ return canonical ? [canonical] : [];
10645
+ });
10646
+ const before = this.state;
10647
+ this.state = {
10648
+ definition: nextDefinition,
10649
+ tiles
10650
+ };
10651
+ const command = new FogResetCommand(this, before, this.state);
10652
+ this.onCommand?.(command);
10653
+ this.notifyChange({ kind: "definition" });
10654
+ }
10655
+ reset(base) {
10656
+ if (!this.state) return;
10657
+ const before = this.state;
10658
+ const generation = this.idFactory();
10659
+ this.state = {
10660
+ definition: { ...this.state.definition, base, generation },
10661
+ tiles: []
10662
+ };
10663
+ const command = new FogResetCommand(this, before, this.state);
10664
+ this.onCommand?.(command);
10665
+ this.notifyChange({ kind: "reset" });
10666
+ }
10667
+ disable() {
10668
+ if (!this.state) return;
10669
+ const before = this.state;
10670
+ this.state = null;
10671
+ const command = new FogResetCommand(this, before, null);
10672
+ this.onCommand?.(command);
10673
+ this.notifyChange({ kind: "disable" });
10674
+ }
10675
+ setViewMode(mode) {
10676
+ if (mode === this.viewMode) return;
10677
+ this.viewMode = mode;
10678
+ this.notifyView({ mode });
10679
+ }
10680
+ applyRegion(region, operation) {
10681
+ if (!this.state) return;
10682
+ const result = rasterizeRegion(this.state, region, operation);
10683
+ if (result.noop) return;
10684
+ const before = this.collectTiles(result.changed);
10685
+ const newState = applyRasterResult(this.state, result);
10686
+ this.state = newState;
10687
+ const command = new FogRegionCommand(this, before, result.changed);
10688
+ this.onCommand?.(command);
10689
+ this.notifyChange({
10690
+ kind: "tiles",
10691
+ tiles: result.changed.map((t) => ({ x: t.x, y: t.y }))
10692
+ });
10693
+ }
10694
+ applyPatchDirect(patch, meta) {
10695
+ if (!this.state) return;
10696
+ const result = applyRasterResult(this.state, { changed: patch.tiles, noop: false });
10697
+ this.state = result;
10698
+ this.notifyChange({
10699
+ kind: "tiles",
10700
+ tiles: patch.tiles.map((t) => ({ x: t.x, y: t.y })),
10701
+ origin: meta?.origin
10702
+ });
10703
+ }
10704
+ applyTilesDirect(tiles) {
10705
+ if (!this.state) return;
10706
+ const result = applyRasterResult(this.state, { changed: tiles, noop: false });
10707
+ this.state = result;
10708
+ this.notifyChange({
10709
+ kind: "tiles",
10710
+ tiles: tiles.map((t) => ({ x: t.x, y: t.y }))
10711
+ });
10712
+ }
10713
+ on(event, listener) {
10714
+ if (event === "change") {
10715
+ const l2 = listener;
10716
+ this.changeListeners.add(l2);
10717
+ return () => this.changeListeners.delete(l2);
10718
+ }
10719
+ const l = listener;
10720
+ this.viewListeners.add(l);
10721
+ return () => this.viewListeners.delete(l);
10722
+ }
10723
+ dispose() {
10724
+ this.changeListeners.clear();
10725
+ this.viewListeners.clear();
10726
+ }
10727
+ collectTiles(changed) {
10728
+ if (!this.state) return [];
10729
+ const result = [];
10730
+ for (const c of changed) {
10731
+ const existing = this.state.tiles.find((t) => t.x === c.x && t.y === c.y);
10732
+ if (existing) {
10733
+ result.push(existing);
10734
+ } else {
10735
+ const baseVal = this.state.definition.base === "revealed";
10736
+ result.push({
10737
+ x: c.x,
10738
+ y: c.y,
10739
+ data: encodeBase64(createTileBytes(baseVal))
10740
+ });
10741
+ }
10742
+ }
10743
+ return result;
10744
+ }
10745
+ notifyChange(event) {
10746
+ for (const listener of this.changeListeners) {
10747
+ try {
10748
+ listener(event);
10749
+ } catch {
10750
+ }
10751
+ }
10752
+ }
10753
+ notifyView(event) {
10754
+ for (const listener of this.viewListeners) {
10755
+ try {
10756
+ listener(event);
10757
+ } catch {
10758
+ }
10759
+ }
10760
+ }
10761
+ };
10762
+
9760
10763
  // src/canvas/viewport.ts
9761
10764
  var EMPTY_IDS = [];
9762
10765
  function noop2() {
@@ -9883,8 +10886,13 @@ var Viewport = class _Viewport {
9883
10886
  });
9884
10887
  }
9885
10888
  this.unsubToolChange = this.toolManager.onChange(() => this.contextMenu?.close());
10889
+ this.fogManager = new FogManager({
10890
+ onCommand: (cmd) => this.history.push(cmd)
10891
+ });
10892
+ this.fogRenderer = new FogRenderer(options.fog);
9886
10893
  if (options.minimap) {
9887
10894
  this.minimap = new Minimap(this.wrapper, this);
10895
+ this.minimap.setFogRenderer(this.fogRenderer);
9888
10896
  }
9889
10897
  this.domNodeManager = new DomNodeManager({
9890
10898
  domLayer: this.paintStack,
@@ -9914,7 +10922,18 @@ var Viewport = class _Viewport {
9914
10922
  domNodeManager: this.domNodeManager,
9915
10923
  layerCache,
9916
10924
  marginViewport: this.marginViewport,
9917
- hybridSurface: new HybridRenderSurface(this.paintStack)
10925
+ hybridSurface: new HybridRenderSurface(this.paintStack),
10926
+ fogRenderer: this.fogRenderer
10927
+ });
10928
+ this.fogManager.on("change", () => {
10929
+ this.fogRenderer.setState(this.fogManager.getState());
10930
+ this.renderLoop.requestRender();
10931
+ this.minimap?.invalidateScene();
10932
+ });
10933
+ this.fogManager.on("view", () => {
10934
+ this.fogRenderer.setViewMode(this.fogManager.getViewMode());
10935
+ this.renderLoop.requestRender();
10936
+ this.minimap?.invalidateScene();
9918
10937
  });
9919
10938
  this.unsubHtmlPainters = this.htmlPainters.onChange(() => this.onHtmlRegistryChanged());
9920
10939
  this.unsubCamera = this.camera.onChange(() => {
@@ -10026,6 +11045,8 @@ var Viewport = class _Viewport {
10026
11045
  _smartGuides = false;
10027
11046
  _gridSize;
10028
11047
  renderLoop;
11048
+ fogManager;
11049
+ fogRenderer;
10029
11050
  domNodeManager;
10030
11051
  interactMode;
10031
11052
  onHtmlElementMount;
@@ -10061,6 +11082,9 @@ var Viewport = class _Viewport {
10061
11082
  get ctx() {
10062
11083
  return this.canvasEl.getContext("2d");
10063
11084
  }
11085
+ get fog() {
11086
+ return this.fogManager;
11087
+ }
10064
11088
  get snapToGrid() {
10065
11089
  return this._snapToGrid;
10066
11090
  }
@@ -10145,7 +11169,8 @@ var Viewport = class _Viewport {
10145
11169
  this.store.snapshot(),
10146
11170
  this.camera,
10147
11171
  this.layerManager.snapshot(),
10148
- this.layerManager.activeLayerId
11172
+ this.layerManager.activeLayerId,
11173
+ this.fogManager.getState()
10149
11174
  );
10150
11175
  }
10151
11176
  exportJSON() {
@@ -10167,12 +11192,31 @@ var Viewport = class _Viewport {
10167
11192
  return { ...base, htmlPainters: registry, expectedCanvasTypes: expected };
10168
11193
  }
10169
11194
  async exportImage(options) {
10170
- return exportImage(this.store, this.withHtmlDefaults(options), this.layerManager);
11195
+ const opts = this.withHtmlDefaults(options);
11196
+ if (opts.fog === void 0 && this.fogRenderer.isVisible()) {
11197
+ const state = this.fogManager.getState();
11198
+ if (state) {
11199
+ const mode = this.fogRenderer.getViewMode();
11200
+ opts.fog = { state, mode };
11201
+ }
11202
+ }
11203
+ return exportImage(this.store, opts, this.layerManager);
10171
11204
  }
10172
11205
  async exportSVG(options) {
10173
- return exportSvg(this.store, this.withHtmlDefaults(options), this.layerManager);
11206
+ const opts = this.withHtmlDefaults(options);
11207
+ if (opts.fog === void 0 && this.fogRenderer.isVisible()) {
11208
+ const state = this.fogManager.getState();
11209
+ if (state) {
11210
+ const mode = this.fogRenderer.getViewMode();
11211
+ opts.fog = { state, mode };
11212
+ }
11213
+ }
11214
+ return exportSvg(this.store, opts, this.layerManager);
10174
11215
  }
10175
11216
  loadState(state) {
11217
+ if (state.fog != null) {
11218
+ validateFogState(state.fog);
11219
+ }
10176
11220
  this.inputHandler.flushPendingHistory();
10177
11221
  this.historyRecorder.pause();
10178
11222
  this.noteEditor.destroy(this.store);
@@ -10207,6 +11251,7 @@ var Viewport = class _Viewport {
10207
11251
  }
10208
11252
  }
10209
11253
  }
11254
+ this.fogManager.loadState(state.fog ?? null);
10210
11255
  this.history.clear();
10211
11256
  this.historyRecorder.resume();
10212
11257
  this.camera.moveTo(state.camera.position.x, state.camera.position.y);
@@ -10618,6 +11663,8 @@ var Viewport = class _Viewport {
10618
11663
  this.unsubToolRegister();
10619
11664
  this.unsubRecorderEnd();
10620
11665
  this.unsubHtmlPainters();
11666
+ this.fogManager.dispose();
11667
+ this.fogRenderer.dispose();
10621
11668
  this.activation?.dispose();
10622
11669
  this.activation = null;
10623
11670
  this.activationListeners.clear();
@@ -15459,8 +16506,188 @@ var PingTool = class {
15459
16506
  }
15460
16507
  };
15461
16508
 
16509
+ // src/tools/fog-tool.ts
16510
+ var DEFAULT_RADIUS5 = 40;
16511
+ var MIN_POINT_DISTANCE = 4;
16512
+ var FogTool = class {
16513
+ name = "fog";
16514
+ drawing = false;
16515
+ points = [];
16516
+ startPoint = null;
16517
+ operation;
16518
+ shape;
16519
+ radius;
16520
+ manager;
16521
+ optionListeners = /* @__PURE__ */ new Set();
16522
+ constructor(manager, options = {}) {
16523
+ this.manager = manager;
16524
+ this.operation = options.operation ?? "reveal";
16525
+ this.shape = options.shape ?? "brush";
16526
+ this.radius = options.radius ?? DEFAULT_RADIUS5;
16527
+ }
16528
+ onActivate(ctx) {
16529
+ ctx.setCursor?.("crosshair");
16530
+ }
16531
+ onDeactivate(ctx) {
16532
+ this.cancelGesture(ctx);
16533
+ ctx.setCursor?.("default");
16534
+ }
16535
+ getOptions() {
16536
+ return {
16537
+ operation: this.operation,
16538
+ shape: this.shape,
16539
+ radius: this.radius
16540
+ };
16541
+ }
16542
+ setOptions(options) {
16543
+ if (options.operation !== void 0) this.operation = options.operation;
16544
+ if (options.shape !== void 0) this.shape = options.shape;
16545
+ if (options.radius !== void 0 && Number.isFinite(options.radius) && options.radius > 0) {
16546
+ this.radius = options.radius;
16547
+ }
16548
+ for (const listener of this.optionListeners) listener();
16549
+ }
16550
+ onOptionsChange(listener) {
16551
+ this.optionListeners.add(listener);
16552
+ return () => this.optionListeners.delete(listener);
16553
+ }
16554
+ onPointerDown(state, ctx) {
16555
+ if (this.drawing) return;
16556
+ this.drawing = true;
16557
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
16558
+ this.startPoint = world;
16559
+ this.points = [world];
16560
+ ctx.requestRender();
16561
+ }
16562
+ onPointerMove(state, ctx) {
16563
+ if (!this.drawing) return;
16564
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
16565
+ if (this.shape === "rectangle") {
16566
+ if (this.startPoint) this.points = [this.startPoint, world];
16567
+ } else {
16568
+ const last = this.points[this.points.length - 1];
16569
+ if (last) {
16570
+ const dx = world.x - last.x;
16571
+ const dy = world.y - last.y;
16572
+ if (dx * dx + dy * dy < MIN_POINT_DISTANCE * MIN_POINT_DISTANCE) return;
16573
+ }
16574
+ this.points.push(world);
16575
+ }
16576
+ ctx.requestRender();
16577
+ }
16578
+ onPointerUp(_state, ctx) {
16579
+ if (!this.drawing) return;
16580
+ this.drawing = false;
16581
+ const region = this.buildRegion();
16582
+ if (region) {
16583
+ this.manager.applyRegion(region, this.operation);
16584
+ }
16585
+ this.points = [];
16586
+ this.startPoint = null;
16587
+ ctx.requestRender();
16588
+ }
16589
+ onPointerCancel(_state, ctx) {
16590
+ this.cancelGesture(ctx);
16591
+ }
16592
+ onKeyDown(event, ctx) {
16593
+ if (event.key === "Escape" && this.drawing) {
16594
+ this.cancelGesture(ctx);
16595
+ return true;
16596
+ }
16597
+ return false;
16598
+ }
16599
+ renderOverlay(ctx) {
16600
+ if (!this.drawing || this.points.length === 0) return;
16601
+ ctx.save();
16602
+ ctx.strokeStyle = this.operation === "reveal" ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.4)";
16603
+ ctx.fillStyle = this.operation === "reveal" ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.1)";
16604
+ ctx.lineWidth = 2;
16605
+ ctx.setLineDash([6, 4]);
16606
+ switch (this.shape) {
16607
+ case "brush":
16608
+ this.renderBrushPreview(ctx);
16609
+ break;
16610
+ case "rectangle":
16611
+ this.renderRectanglePreview(ctx);
16612
+ break;
16613
+ case "polygon":
16614
+ this.renderPolygonPreview(ctx);
16615
+ break;
16616
+ }
16617
+ ctx.restore();
16618
+ }
16619
+ buildRegion() {
16620
+ switch (this.shape) {
16621
+ case "brush": {
16622
+ if (this.points.length === 0) return null;
16623
+ return { kind: "brush", points: this.points, radius: this.radius };
16624
+ }
16625
+ case "rectangle": {
16626
+ if (!this.startPoint || this.points.length < 2) return null;
16627
+ const end = this.points[this.points.length - 1];
16628
+ if (this.startPoint.x === end.x && this.startPoint.y === end.y) return null;
16629
+ return { kind: "rectangle", from: this.startPoint, to: end };
16630
+ }
16631
+ case "polygon": {
16632
+ if (this.points.length < 3) return null;
16633
+ return { kind: "polygon", points: this.points };
16634
+ }
16635
+ }
16636
+ }
16637
+ cancelGesture(ctx) {
16638
+ this.drawing = false;
16639
+ this.points = [];
16640
+ this.startPoint = null;
16641
+ ctx.requestRender();
16642
+ }
16643
+ renderBrushPreview(ctx) {
16644
+ if (this.points.length === 1) {
16645
+ const p = this.points[0];
16646
+ ctx.beginPath();
16647
+ ctx.arc(p.x, p.y, this.radius, 0, Math.PI * 2);
16648
+ ctx.fill();
16649
+ ctx.stroke();
16650
+ return;
16651
+ }
16652
+ ctx.beginPath();
16653
+ for (let i = 0; i < this.points.length; i++) {
16654
+ const p = this.points[i];
16655
+ if (i === 0) ctx.moveTo(p.x, p.y);
16656
+ else ctx.lineTo(p.x, p.y);
16657
+ }
16658
+ ctx.lineWidth = this.radius * 2;
16659
+ ctx.lineCap = "round";
16660
+ ctx.lineJoin = "round";
16661
+ ctx.stroke();
16662
+ }
16663
+ renderRectanglePreview(ctx) {
16664
+ if (this.points.length < 2) return;
16665
+ const from = this.points[0];
16666
+ const to = this.points[this.points.length - 1];
16667
+ const x = Math.min(from.x, to.x);
16668
+ const y = Math.min(from.y, to.y);
16669
+ const w = Math.abs(to.x - from.x);
16670
+ const h = Math.abs(to.y - from.y);
16671
+ ctx.fillRect(x, y, w, h);
16672
+ ctx.strokeRect(x, y, w, h);
16673
+ }
16674
+ renderPolygonPreview(ctx) {
16675
+ if (this.points.length < 2) return;
16676
+ const first = this.points[0];
16677
+ ctx.beginPath();
16678
+ ctx.moveTo(first.x, first.y);
16679
+ for (let i = 1; i < this.points.length; i++) {
16680
+ const p = this.points[i];
16681
+ ctx.lineTo(p.x, p.y);
16682
+ }
16683
+ ctx.closePath();
16684
+ ctx.fill();
16685
+ ctx.stroke();
16686
+ }
16687
+ };
16688
+
15462
16689
  // src/index.ts
15463
- var VERSION = "0.65.0";
16690
+ var VERSION = "0.66.0";
15464
16691
  export {
15465
16692
  AWARENESS_MAX_SELECTION,
15466
16693
  AWARENESS_PRESENCE_KIND,
@@ -15473,6 +16700,12 @@ export {
15473
16700
  ElementStore,
15474
16701
  EraserTool,
15475
16702
  FOCUS_PRESENCE_KIND,
16703
+ FOG_MAX_TILES,
16704
+ FOG_STATE_VERSION,
16705
+ FOG_TILE_CELLS,
16706
+ FogManager,
16707
+ FogRenderer,
16708
+ FogTool,
15476
16709
  HandTool,
15477
16710
  HistoryStack,
15478
16711
  HtmlPainterMissingError,
@@ -15516,6 +16749,7 @@ export {
15516
16749
  attachAwareness,
15517
16750
  boundsIntersect,
15518
16751
  cameraOriginForView,
16752
+ canonicalizeFogTile,
15519
16753
  captureCameraView,
15520
16754
  computeElementRects,
15521
16755
  createArrow,
@@ -15533,6 +16767,8 @@ export {
15533
16767
  exportImage,
15534
16768
  exportSvg,
15535
16769
  fitZoomForView,
16770
+ decodeBase64 as fogDecodeBase64,
16771
+ encodeBase64 as fogEncodeBase64,
15536
16772
  footprintFromSize,
15537
16773
  getActiveFormats,
15538
16774
  getArrowBounds,
@@ -15558,6 +16794,7 @@ export {
15558
16794
  isPathPresence,
15559
16795
  isPingPresence,
15560
16796
  pathDistanceCells,
16797
+ recommendedFogCellSize,
15561
16798
  resolveHtmlRouting,
15562
16799
  setFontSize,
15563
16800
  smartSnap,
@@ -15574,6 +16811,9 @@ export {
15574
16811
  toggleBold,
15575
16812
  toggleItalic,
15576
16813
  toggleStrikethrough,
15577
- toggleUnderline
16814
+ toggleUnderline,
16815
+ validateFogDefinition,
16816
+ validateFogState,
16817
+ validateFogTile
15578
16818
  };
15579
16819
  //# sourceMappingURL=index.js.map