@connected-web/terrain-editor 0.1.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 ADDED
@@ -0,0 +1,3213 @@
1
+ // src/geometry.ts
2
+ import * as THREE from "three";
3
+ function getImageSize(img) {
4
+ if ("naturalWidth" in img) {
5
+ return {
6
+ width: img.naturalWidth || img.width,
7
+ height: img.naturalHeight || img.height
8
+ };
9
+ }
10
+ return {
11
+ width: img.width,
12
+ height: img.height
13
+ };
14
+ }
15
+ function createHeightSampler(texture) {
16
+ if (typeof document === "undefined") return null;
17
+ const image = texture.image;
18
+ if (!image) return null;
19
+ const { width, height } = getImageSize(image);
20
+ if (!width || !height) return null;
21
+ const canvas = document.createElement("canvas");
22
+ canvas.width = width;
23
+ canvas.height = height;
24
+ const ctx = canvas.getContext("2d");
25
+ if (!ctx) return null;
26
+ ctx.drawImage(image, 0, 0, width, height);
27
+ const pixels = ctx.getImageData(0, 0, width, height).data;
28
+ const data = new Float32Array(width * height);
29
+ for (let i = 0; i < data.length; i += 1) {
30
+ data[i] = pixels[i * 4] / 255;
31
+ }
32
+ return { width, height, data };
33
+ }
34
+ function sampleHeightValue(sampler, u, v) {
35
+ const x = THREE.MathUtils.clamp(u, 0, 1) * (sampler.width - 1);
36
+ const y = (1 - THREE.MathUtils.clamp(v, 0, 1)) * (sampler.height - 1);
37
+ const x0 = Math.floor(x);
38
+ const x1 = Math.min(sampler.width - 1, Math.ceil(x));
39
+ const y0 = Math.floor(y);
40
+ const y1 = Math.min(sampler.height - 1, Math.ceil(y));
41
+ const tx = x - x0;
42
+ const ty = y - y0;
43
+ function idx(ix, iy) {
44
+ return sampler.data[iy * sampler.width + ix];
45
+ }
46
+ const a = idx(x0, y0);
47
+ const b = idx(x1, y0);
48
+ const c = idx(x0, y1);
49
+ const d = idx(x1, y1);
50
+ const ab = a + (b - a) * tx;
51
+ const cd = c + (d - c) * tx;
52
+ return ab + (cd - ab) * ty;
53
+ }
54
+ function applyHeightField(geometry, sampler, options) {
55
+ const { seaLevel, heightScale } = options;
56
+ const positions = geometry.attributes.position;
57
+ const uvs = geometry.attributes.uv;
58
+ let minY = Infinity;
59
+ let maxY = -Infinity;
60
+ for (let i = 0; i < positions.count; i += 1) {
61
+ const u = uvs.getX(i);
62
+ const v = uvs.getY(i);
63
+ const sampled = sampleHeightValue(sampler, u, v);
64
+ const worldY = (sampled - seaLevel) * heightScale;
65
+ positions.setY(i, worldY);
66
+ minY = Math.min(minY, worldY);
67
+ maxY = Math.max(maxY, worldY);
68
+ }
69
+ positions.needsUpdate = true;
70
+ geometry.computeVertexNormals();
71
+ return { minY, maxY };
72
+ }
73
+ function collectBoundaryIndices(cols, rows) {
74
+ const indices = [];
75
+ for (let col = 0; col < cols; col += 1) indices.push((rows - 1) * cols + col);
76
+ for (let row = rows - 2; row >= 0; row -= 1) indices.push(row * cols + (cols - 1));
77
+ for (let col = cols - 2; col >= 0; col -= 1) indices.push(col);
78
+ for (let row = 1; row < rows - 1; row += 1) indices.push(row * cols);
79
+ return indices;
80
+ }
81
+ function buildRimMesh(geometry, floorY, material) {
82
+ const positions = geometry.attributes.position;
83
+ const cols = geometry.parameters.widthSegments + 1;
84
+ const rows = geometry.parameters.heightSegments + 1;
85
+ const ring = collectBoundaryIndices(cols, rows);
86
+ const rimPositions = [];
87
+ const rimNormals = [];
88
+ const rimIndices = [];
89
+ function getVertex(idx) {
90
+ return new THREE.Vector3(positions.getX(idx), positions.getY(idx), positions.getZ(idx));
91
+ }
92
+ for (let i = 0; i < ring.length; i += 1) {
93
+ const current = getVertex(ring[i]);
94
+ const next = getVertex(ring[(i + 1) % ring.length]);
95
+ const base = rimPositions.length / 3;
96
+ const verts = [
97
+ current,
98
+ next,
99
+ new THREE.Vector3(next.x, floorY, next.z),
100
+ new THREE.Vector3(current.x, floorY, current.z)
101
+ ];
102
+ verts.forEach((v) => rimPositions.push(v.x, v.y, v.z));
103
+ const edgeDir = new THREE.Vector3().subVectors(next, current);
104
+ const downDir = new THREE.Vector3().subVectors(
105
+ new THREE.Vector3(current.x, floorY, current.z),
106
+ current
107
+ );
108
+ const normal = new THREE.Vector3().crossVectors(edgeDir, downDir).normalize();
109
+ if (!Number.isFinite(normal.x)) normal.set(0, 1, 0);
110
+ for (let n = 0; n < 4; n += 1) {
111
+ rimNormals.push(normal.x, normal.y, normal.z);
112
+ }
113
+ rimIndices.push(base, base + 1, base + 2, base, base + 2, base + 3);
114
+ }
115
+ const rimGeometry = new THREE.BufferGeometry();
116
+ rimGeometry.setAttribute("position", new THREE.Float32BufferAttribute(rimPositions, 3));
117
+ rimGeometry.setAttribute("normal", new THREE.Float32BufferAttribute(rimNormals, 3));
118
+ rimGeometry.setIndex(rimIndices);
119
+ const rimMaterial = material || new THREE.MeshStandardMaterial({
120
+ color: 1315359,
121
+ roughness: 0.55,
122
+ metalness: 0.2,
123
+ side: THREE.DoubleSide
124
+ });
125
+ return new THREE.Mesh(rimGeometry, rimMaterial);
126
+ }
127
+
128
+ // src/terrainViewer.ts
129
+ import * as THREE2 from "three";
130
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
131
+
132
+ // src/config/default-theme.config.json
133
+ var default_theme_config_default = {
134
+ locationMarkers: {
135
+ sprite: {
136
+ fontFamily: '"DM Sans", sans-serif',
137
+ fontWeight: "600",
138
+ maxFontSize: 52,
139
+ minFontSize: 22,
140
+ paddingX: 20,
141
+ paddingY: 10,
142
+ borderRadius: 18,
143
+ states: {
144
+ default: {
145
+ textColor: "#ffffff",
146
+ backgroundColor: "rgba(8, 10, 18, 0.78)",
147
+ borderColor: "rgba(255, 255, 255, 0.35)",
148
+ borderThickness: 2,
149
+ opacity: 0.85
150
+ },
151
+ hover: {
152
+ backgroundColor: "rgba(8, 10, 18, 0.9)",
153
+ borderColor: "rgba(255, 255, 255, 0.6)",
154
+ opacity: 0.95
155
+ },
156
+ focus: {
157
+ textColor: "#111216",
158
+ backgroundColor: "rgba(255, 240, 218, 0.95)",
159
+ borderColor: "#f1d9a6",
160
+ borderThickness: 3,
161
+ opacity: 1
162
+ }
163
+ }
164
+ },
165
+ stem: {
166
+ shape: "cylinder",
167
+ radius: 0.015,
168
+ scale: 0.01,
169
+ states: {
170
+ default: {
171
+ color: "#d9c39c",
172
+ opacity: 0.75
173
+ },
174
+ hover: {
175
+ color: "#f6dfb5",
176
+ opacity: 0.95
177
+ },
178
+ focus: {
179
+ color: "#ffe7b5",
180
+ opacity: 1
181
+ }
182
+ }
183
+ }
184
+ }
185
+ };
186
+
187
+ // src/theme.ts
188
+ var defaultTheme = default_theme_config_default;
189
+ function getDefaultTerrainTheme() {
190
+ return clone(defaultTheme);
191
+ }
192
+ function resolveTerrainTheme(...overrides) {
193
+ const theme = getDefaultTerrainTheme();
194
+ overrides.forEach((candidate) => {
195
+ if (candidate) {
196
+ deepMerge(theme, candidate);
197
+ }
198
+ });
199
+ return theme;
200
+ }
201
+ function isObject(value) {
202
+ return typeof value === "object" && value !== null && !Array.isArray(value);
203
+ }
204
+ function clone(value) {
205
+ if (Array.isArray(value)) {
206
+ return value.map((entry) => clone(entry));
207
+ }
208
+ if (isObject(value)) {
209
+ const result = {};
210
+ Object.entries(value).forEach(([key, entry]) => {
211
+ result[key] = clone(entry);
212
+ });
213
+ return result;
214
+ }
215
+ return value;
216
+ }
217
+ function deepMerge(target, source) {
218
+ if (!isObject(target) || !isObject(source)) {
219
+ return source;
220
+ }
221
+ Object.keys(source).forEach((key) => {
222
+ const typedKey = key;
223
+ const sourceValue = source[typedKey];
224
+ if (sourceValue === void 0) return;
225
+ const targetValue = target[key];
226
+ if (isObject(sourceValue)) {
227
+ const nextTarget = isObject(targetValue) ? targetValue : {};
228
+ target[key] = deepMerge(nextTarget, sourceValue);
229
+ } else if (Array.isArray(sourceValue)) {
230
+ ;
231
+ target[key] = sourceValue.map((entry) => clone(entry));
232
+ } else {
233
+ ;
234
+ target[key] = sourceValue;
235
+ }
236
+ });
237
+ return target;
238
+ }
239
+
240
+ // src/terrainViewer.ts
241
+ var DEFAULT_MAP_WIDTH = 512;
242
+ var DEFAULT_MAP_HEIGHT = 512;
243
+ var DEFAULT_MAP_RATIO = DEFAULT_MAP_HEIGHT / DEFAULT_MAP_WIDTH;
244
+ var DEFAULT_TERRAIN_WIDTH = 4.2;
245
+ var DEFAULT_TERRAIN_DEPTH = DEFAULT_TERRAIN_WIDTH * DEFAULT_MAP_RATIO;
246
+ var DEFAULT_SEGMENTS_X = 256;
247
+ var MIN_TERRAIN_SEGMENTS = 64;
248
+ var MAX_TERRAIN_SEGMENTS = 512;
249
+ var EDGE_RIM = 0.25;
250
+ var BASE_THICKNESS = 0.65;
251
+ var SEA_LEVEL_DEFAULT = 0.28;
252
+ var FLOOR_Y = -BASE_THICKNESS - 0.22;
253
+ var HEIGHT_SCALE_DEFAULT = 0.3;
254
+ var WATER_PERCENT_DEFAULT = 65;
255
+ var WATER_MIN = -0.08;
256
+ var WATER_MAX = 0.14;
257
+ var WATER_INSET = 0.03;
258
+ var DEFAULT_LAYER_ALPHA = {
259
+ water: 0.92,
260
+ rivers: 0.95,
261
+ cities: 1,
262
+ roads: 0.85
263
+ };
264
+ var DEFAULT_STEM_SCALE = 0.01;
265
+ var MARKER_HEIGHT_RATIO = 0.11;
266
+ var MARKER_HEIGHT_SCALE = 0.25;
267
+ var MARKER_MIN_HEIGHT = 0.35;
268
+ var MARKER_MAX_HEIGHT = 0.85;
269
+ var MARKER_SPRITE_GAP = 0.08;
270
+ var MARKER_LABEL_EXTRA_OFFSET = -0.08;
271
+ var MARKER_SURFACE_OFFSET = 0;
272
+ var BASE_FILL_COLOR = "#7b5c3a";
273
+ var SPRITE_CANVAS_WIDTH = 240;
274
+ var SPRITE_CANVAS_HEIGHT = 160;
275
+ var ICON_TEXTURE_SIZE = 256;
276
+ var ICON_CANVAS_MARGIN = 18;
277
+ var ICON_SCALE_MULTIPLIER = 0.5;
278
+ var ICON_ASSET_PATTERN = /\.(png|jpe?g|gif|webp|svg)$/i;
279
+ function isAssetIconReference(value) {
280
+ if (!value) return false;
281
+ return value.includes("/") || ICON_ASSET_PATTERN.test(value);
282
+ }
283
+ function uvToWorld(u, v, sampler, heightScale, seaLevel, dimensions = {
284
+ width: DEFAULT_TERRAIN_WIDTH,
285
+ depth: DEFAULT_TERRAIN_DEPTH
286
+ }) {
287
+ if (!sampler) return null;
288
+ const heightSample = sampleHeightValue(sampler, u, v);
289
+ const x = (u - 0.5) * dimensions.width;
290
+ const z = (v - 0.5) * dimensions.depth;
291
+ const y = (heightSample - seaLevel) * heightScale;
292
+ return new THREE2.Vector3(x, y, z);
293
+ }
294
+ function worldToPixel(point, dimensions, mapSize) {
295
+ const width = dimensions.width || 1;
296
+ const depth = dimensions.depth || 1;
297
+ const u = THREE2.MathUtils.clamp(point.x / width + 0.5, 0, 1);
298
+ const v = THREE2.MathUtils.clamp(point.z / depth + 0.5, 0, 1);
299
+ return {
300
+ x: u * mapSize.width,
301
+ y: v * mapSize.height
302
+ };
303
+ }
304
+ function easeInOut(t) {
305
+ return t * t * (3 - 2 * t);
306
+ }
307
+ async function loadLegendImage(file, resolveAssetUrl, cache) {
308
+ if (cache.has(file)) return cache.get(file);
309
+ const url = await Promise.resolve(resolveAssetUrl(file));
310
+ const image = await new Promise((resolve, reject) => {
311
+ const img = new Image();
312
+ img.decoding = "async";
313
+ img.src = url;
314
+ img.onload = () => resolve(img);
315
+ img.onerror = (event) => reject(new Error(`Failed to load ${file} (${url})`));
316
+ });
317
+ cache.set(file, image);
318
+ return image;
319
+ }
320
+ async function preprocessMask(file, resolveAssetUrl, imageCache, maskCache) {
321
+ if (maskCache.has(file)) return maskCache.get(file);
322
+ const img = await loadLegendImage(file, resolveAssetUrl, imageCache);
323
+ const canvas = document.createElement("canvas");
324
+ canvas.width = img.naturalWidth || img.width;
325
+ canvas.height = img.naturalHeight || img.height;
326
+ const ctx = canvas.getContext("2d");
327
+ if (!ctx) return canvas;
328
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
329
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
330
+ const data = imageData.data;
331
+ for (let i = 0; i < data.length; i += 4) {
332
+ const r = data[i];
333
+ const g = data[i + 1];
334
+ const b = data[i + 2];
335
+ const alpha = Math.max(r, g, b);
336
+ data[i] = 255;
337
+ data[i + 1] = 255;
338
+ data[i + 2] = 255;
339
+ data[i + 3] = alpha;
340
+ }
341
+ ctx.putImageData(imageData, 0, 0);
342
+ maskCache.set(file, canvas);
343
+ return canvas;
344
+ }
345
+ function hexFromRgb(rgb) {
346
+ return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
347
+ }
348
+ async function composeLegendTexture(legendData, resolveAssetUrl, imageCache, maskCache, layerState) {
349
+ if (typeof document === "undefined") return null;
350
+ const [width, height] = legendData.size;
351
+ const canvas = document.createElement("canvas");
352
+ canvas.width = width;
353
+ canvas.height = height;
354
+ const ctxRaw = canvas.getContext("2d");
355
+ if (!ctxRaw) return null;
356
+ const ctx = ctxRaw;
357
+ ctx.fillStyle = BASE_FILL_COLOR;
358
+ ctx.fillRect(0, 0, width, height);
359
+ const temp = document.createElement("canvas");
360
+ temp.width = width;
361
+ temp.height = height;
362
+ const tempCtxRaw = temp.getContext("2d");
363
+ if (!tempCtxRaw) return null;
364
+ const tempCtx = tempCtxRaw;
365
+ async function drawLayer(maskFile, color, alpha = 1) {
366
+ let maskImage = null;
367
+ try {
368
+ maskImage = await preprocessMask(maskFile, resolveAssetUrl, imageCache, maskCache);
369
+ } catch (err) {
370
+ console.warn("[WynnalTerrain] Unable to load mask", maskFile, err);
371
+ return;
372
+ }
373
+ tempCtx.globalCompositeOperation = "source-over";
374
+ tempCtx.globalAlpha = 1;
375
+ tempCtx.clearRect(0, 0, width, height);
376
+ tempCtx.fillStyle = hexFromRgb(color);
377
+ tempCtx.fillRect(0, 0, width, height);
378
+ tempCtx.globalAlpha = 1;
379
+ tempCtx.globalCompositeOperation = "destination-in";
380
+ tempCtx.drawImage(maskImage, 0, 0, width, height);
381
+ ctx.globalAlpha = alpha;
382
+ ctx.drawImage(temp, 0, 0, width, height);
383
+ }
384
+ const activeBiomes = layerState?.biomes ?? {};
385
+ for (const [name, biome] of Object.entries(legendData.biomes)) {
386
+ if (layerState && activeBiomes[name] === false) continue;
387
+ await drawLayer(biome.mask, biome.rgb);
388
+ }
389
+ const activeOverlays = layerState?.overlays ?? {};
390
+ for (const [name, overlay] of Object.entries(legendData.overlays)) {
391
+ if (layerState && activeOverlays[name] === false) continue;
392
+ const alpha = DEFAULT_LAYER_ALPHA[name] ?? 1;
393
+ await drawLayer(overlay.mask, overlay.rgb, alpha);
394
+ }
395
+ const texture = new THREE2.CanvasTexture(canvas);
396
+ texture.colorSpace = THREE2.SRGBColorSpace;
397
+ texture.anisotropy = 4;
398
+ return texture;
399
+ }
400
+ function drawRoundedRect(ctx, x, y, width, height, radius) {
401
+ ctx.beginPath();
402
+ ctx.moveTo(x + radius, y);
403
+ ctx.lineTo(x + width - radius, y);
404
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
405
+ ctx.lineTo(x + width, y + height - radius);
406
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
407
+ ctx.lineTo(x + radius, y + height);
408
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
409
+ ctx.lineTo(x, y + radius);
410
+ ctx.quadraticCurveTo(x, y, x + radius, y);
411
+ ctx.closePath();
412
+ }
413
+ function resolveStemState(stemTheme, state) {
414
+ const base = stemTheme.states.default;
415
+ const overrides = state === "default" ? {} : state === "hover" ? stemTheme.states.hover ?? {} : stemTheme.states.focus ?? {};
416
+ return {
417
+ ...base,
418
+ ...overrides
419
+ };
420
+ }
421
+ function createMarkerStemVisuals(stemTheme) {
422
+ return {
423
+ default: resolveStemState(stemTheme, "default"),
424
+ hover: resolveStemState(stemTheme, "hover"),
425
+ focus: resolveStemState(stemTheme, "focus")
426
+ };
427
+ }
428
+ function getStemSegments(shape) {
429
+ switch (shape) {
430
+ case "triangle":
431
+ return 3;
432
+ case "square":
433
+ return 4;
434
+ case "pentagon":
435
+ return 5;
436
+ case "hexagon":
437
+ return 6;
438
+ case "cylinder":
439
+ default:
440
+ return 8;
441
+ }
442
+ }
443
+ function createStemGeometry(shape, radius, height) {
444
+ const radialSegments = getStemSegments(shape);
445
+ return new THREE2.CylinderGeometry(radius, radius, height, radialSegments, 1, false);
446
+ }
447
+ function markerStateStylesEqual(a, b) {
448
+ return a.textColor === b.textColor && a.backgroundColor === b.backgroundColor && a.borderColor === b.borderColor && a.borderThickness === b.borderThickness && a.opacity === b.opacity;
449
+ }
450
+ function resolveSpriteState(spriteTheme, state) {
451
+ const base = spriteTheme.states.default;
452
+ const overrides = state === "default" ? {} : state === "hover" ? spriteTheme.states.hover ?? {} : spriteTheme.states.focus ?? {};
453
+ return {
454
+ ...base,
455
+ ...overrides
456
+ };
457
+ }
458
+ function createIconSpriteTexture(iconTexture, style, showBorder) {
459
+ const source = iconTexture.image;
460
+ if (!source) {
461
+ const fallback = iconTexture.clone();
462
+ fallback.needsUpdate = true;
463
+ return fallback;
464
+ }
465
+ const canvas = document.createElement("canvas");
466
+ canvas.width = ICON_TEXTURE_SIZE;
467
+ canvas.height = ICON_TEXTURE_SIZE;
468
+ const ctx = canvas.getContext("2d");
469
+ if (!ctx) {
470
+ const fallback = iconTexture.clone();
471
+ fallback.needsUpdate = true;
472
+ return fallback;
473
+ }
474
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
475
+ ctx.save();
476
+ if (showBorder) {
477
+ const borderRadius = 24;
478
+ const inset = 12;
479
+ ctx.fillStyle = style.backgroundColor;
480
+ drawRoundedRect(
481
+ ctx,
482
+ inset,
483
+ inset,
484
+ canvas.width - inset * 2,
485
+ canvas.height - inset * 2,
486
+ borderRadius
487
+ );
488
+ ctx.fill();
489
+ if (style.borderThickness > 0) {
490
+ ctx.strokeStyle = style.borderColor;
491
+ ctx.lineWidth = Math.max(1, style.borderThickness * 1.5);
492
+ ctx.stroke();
493
+ }
494
+ }
495
+ ctx.restore();
496
+ const imageLike = source;
497
+ const rawWidth = imageLike?.naturalWidth ?? imageLike?.width ?? ICON_TEXTURE_SIZE;
498
+ const rawHeight = imageLike?.naturalHeight ?? imageLike?.height ?? ICON_TEXTURE_SIZE;
499
+ const aspect = rawWidth > 0 && rawHeight > 0 ? rawWidth / rawHeight : 1;
500
+ const margin = ICON_CANVAS_MARGIN + (showBorder ? 16 : 4);
501
+ const maxWidth = canvas.width - margin * 2;
502
+ const maxHeight = canvas.height - margin * 2;
503
+ let drawWidth = maxWidth;
504
+ let drawHeight = drawWidth / aspect;
505
+ if (drawHeight > maxHeight) {
506
+ drawHeight = maxHeight;
507
+ drawWidth = drawHeight * aspect;
508
+ }
509
+ const offsetX = (canvas.width - drawWidth) / 2;
510
+ const offsetY = (canvas.height - drawHeight) / 2;
511
+ ctx.drawImage(source, offsetX, offsetY, drawWidth, drawHeight);
512
+ const texture = new THREE2.CanvasTexture(canvas);
513
+ texture.colorSpace = THREE2.SRGBColorSpace;
514
+ return texture;
515
+ }
516
+ function createMarkerSpriteResource(label, spriteTheme, style, options) {
517
+ if (options?.iconTexture) {
518
+ const texture2 = createIconSpriteTexture(
519
+ options.iconTexture,
520
+ style,
521
+ options.showBorder ?? true
522
+ );
523
+ texture2.needsUpdate = true;
524
+ const material2 = new THREE2.SpriteMaterial({
525
+ map: texture2,
526
+ transparent: true,
527
+ depthWrite: false,
528
+ opacity: style.opacity
529
+ });
530
+ return { material: material2, texture: texture2 };
531
+ }
532
+ const text = (label || "?").trim().slice(0, 14);
533
+ const canvas = document.createElement("canvas");
534
+ canvas.width = SPRITE_CANVAS_WIDTH;
535
+ canvas.height = SPRITE_CANVAS_HEIGHT;
536
+ const ctx = canvas.getContext("2d");
537
+ if (ctx) {
538
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
539
+ ctx.textAlign = "center";
540
+ ctx.textBaseline = "middle";
541
+ let fontSize = spriteTheme.maxFontSize;
542
+ const minFontSize = spriteTheme.minFontSize;
543
+ ctx.font = `${spriteTheme.fontWeight} ${fontSize}px ${spriteTheme.fontFamily}`;
544
+ let metrics = ctx.measureText(text);
545
+ const maxBoxWidth = canvas.width - 12 - spriteTheme.paddingX * 2;
546
+ while (metrics.width > maxBoxWidth && fontSize > minFontSize) {
547
+ fontSize -= 2;
548
+ ctx.font = `${spriteTheme.fontWeight} ${fontSize}px ${spriteTheme.fontFamily}`;
549
+ metrics = ctx.measureText(text);
550
+ }
551
+ const boxWidth = Math.min(canvas.width - 12, metrics.width + spriteTheme.paddingX * 2);
552
+ const boxHeight = fontSize + spriteTheme.paddingY * 2;
553
+ const boxX = (canvas.width - boxWidth) / 2;
554
+ const boxY = (canvas.height - boxHeight) / 2;
555
+ if (options?.showBorder ?? true) {
556
+ ctx.fillStyle = style.backgroundColor;
557
+ drawRoundedRect(ctx, boxX, boxY, boxWidth, boxHeight, spriteTheme.borderRadius);
558
+ ctx.fill();
559
+ if (style.borderThickness > 0) {
560
+ ctx.strokeStyle = style.borderColor;
561
+ ctx.lineWidth = style.borderThickness;
562
+ ctx.stroke();
563
+ }
564
+ }
565
+ ctx.fillStyle = style.textColor;
566
+ ctx.fillText(text, canvas.width / 2, canvas.height / 2 + fontSize * 0.05);
567
+ }
568
+ const texture = new THREE2.CanvasTexture(canvas);
569
+ texture.colorSpace = THREE2.SRGBColorSpace;
570
+ const material = new THREE2.SpriteMaterial({
571
+ map: texture,
572
+ transparent: true,
573
+ depthWrite: false,
574
+ opacity: style.opacity
575
+ });
576
+ return { material, texture };
577
+ }
578
+ function createMarkerSpriteVisuals(label, spriteTheme, options) {
579
+ const defaultStyle = resolveSpriteState(spriteTheme, "default");
580
+ const hoverStyle = resolveSpriteState(spriteTheme, "hover");
581
+ const focusStyle = resolveSpriteState(spriteTheme, "focus");
582
+ const defaultResource = createMarkerSpriteResource(label, spriteTheme, defaultStyle, options);
583
+ const hoverResource = markerStateStylesEqual(defaultStyle, hoverStyle) ? defaultResource : createMarkerSpriteResource(label, spriteTheme, hoverStyle, options);
584
+ const focusResource = markerStateStylesEqual(defaultStyle, focusStyle) ? defaultResource : markerStateStylesEqual(hoverStyle, focusStyle) ? hoverResource : createMarkerSpriteResource(label, spriteTheme, focusStyle, options);
585
+ return {
586
+ default: defaultResource,
587
+ hover: hoverResource,
588
+ focus: focusResource
589
+ };
590
+ }
591
+ function createGradientTexture(renderer) {
592
+ if (typeof window === "undefined") return null;
593
+ const canvas = document.createElement("canvas");
594
+ canvas.width = 2;
595
+ canvas.height = 2;
596
+ const ctx = canvas.getContext("2d");
597
+ if (!ctx) return null;
598
+ const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
599
+ gradient.addColorStop(0, "#08152b");
600
+ gradient.addColorStop(1, "#010207");
601
+ ctx.fillStyle = gradient;
602
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
603
+ const texture = new THREE2.CanvasTexture(canvas);
604
+ texture.colorSpace = THREE2.SRGBColorSpace;
605
+ texture.mapping = THREE2.EquirectangularReflectionMapping;
606
+ const pmrem = new THREE2.PMREMGenerator(renderer);
607
+ const envRenderTarget = pmrem.fromEquirectangular(texture);
608
+ pmrem.dispose();
609
+ return { background: texture, environment: envRenderTarget };
610
+ }
611
+ function createBaseSlice(width, depth) {
612
+ const geometry = new THREE2.BoxGeometry(width, BASE_THICKNESS, depth);
613
+ const material = new THREE2.MeshStandardMaterial({
614
+ color: 986904,
615
+ roughness: 0.85,
616
+ metalness: 0.12
617
+ });
618
+ const mesh = new THREE2.Mesh(geometry, material);
619
+ mesh.position.y = FLOOR_Y + BASE_THICKNESS / 2;
620
+ mesh.receiveShadow = true;
621
+ return {
622
+ mesh,
623
+ dispose: () => {
624
+ geometry.dispose();
625
+ material.dispose();
626
+ }
627
+ };
628
+ }
629
+ function createOceanMesh(heightMap, sampler, heightScale, waterHeight, seaLevel, oceanWidth, oceanDepth) {
630
+ const surfaceWidth = Math.max(0, oceanWidth - WATER_INSET);
631
+ const surfaceDepth = Math.max(0, oceanDepth - WATER_INSET);
632
+ const surfaceGeometry = new THREE2.PlaneGeometry(surfaceWidth, surfaceDepth, 1, 1);
633
+ surfaceGeometry.rotateX(-Math.PI / 2);
634
+ surfaceGeometry.translate(0, waterHeight, 0);
635
+ const uniforms = {
636
+ uHeightMap: { value: heightMap },
637
+ uSeaLevel: { value: seaLevel },
638
+ uHeightScale: { value: heightScale },
639
+ uWaterHeight: { value: waterHeight },
640
+ uLowColor: { value: new THREE2.Color("#1b3d4f") },
641
+ uHighColor: { value: new THREE2.Color("#4f99ac") },
642
+ uFoamColor: { value: new THREE2.Color("#dfeff4") },
643
+ uOpacity: { value: 0.9 }
644
+ };
645
+ const vertexShader = (
646
+ /* glsl */
647
+ `
648
+ varying vec2 vUv;
649
+ void main() {
650
+ vUv = uv;
651
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
652
+ }
653
+ `
654
+ );
655
+ const fragmentShader = (
656
+ /* glsl */
657
+ `
658
+ precision mediump float;
659
+ uniform sampler2D uHeightMap;
660
+ uniform float uSeaLevel;
661
+ uniform float uHeightScale;
662
+ uniform float uWaterHeight;
663
+ uniform vec3 uLowColor;
664
+ uniform vec3 uHighColor;
665
+ uniform vec3 uFoamColor;
666
+ uniform float uOpacity;
667
+ varying vec2 vUv;
668
+
669
+ void main() {
670
+ float heightSample = texture2D(uHeightMap, vUv).r;
671
+ float worldY = (heightSample - uSeaLevel) * uHeightScale;
672
+ float mask = clamp(smoothstep(0.08, -0.03, worldY - uWaterHeight), 0.0, 1.0);
673
+ if (mask <= 0.001) discard;
674
+ float foam = smoothstep(0.05, 0.0, abs(worldY - uWaterHeight));
675
+ vec3 color = mix(uLowColor, uHighColor, mask);
676
+ color = mix(color, uFoamColor, foam * 0.25);
677
+ gl_FragColor = vec4(color, mask * uOpacity);
678
+ }
679
+ `
680
+ );
681
+ const surfaceMaterial = new THREE2.ShaderMaterial({
682
+ uniforms,
683
+ vertexShader,
684
+ fragmentShader,
685
+ transparent: true,
686
+ depthWrite: false,
687
+ side: THREE2.DoubleSide
688
+ });
689
+ const waterRenderOrder = -10;
690
+ const surfaceMesh = new THREE2.Mesh(surfaceGeometry, surfaceMaterial);
691
+ surfaceMesh.receiveShadow = true;
692
+ surfaceMesh.renderOrder = waterRenderOrder;
693
+ const waterBottom = FLOOR_Y + BASE_THICKNESS;
694
+ const waterDepth = Math.max(0.01, waterHeight - waterBottom);
695
+ const volumeCenterY = waterBottom + waterDepth / 2;
696
+ const sideMaterial = new THREE2.MeshStandardMaterial({
697
+ color: "#1d415a",
698
+ transparent: true,
699
+ opacity: 0.4,
700
+ roughness: 0.55,
701
+ metalness: 0.1,
702
+ depthWrite: false,
703
+ side: THREE2.DoubleSide,
704
+ polygonOffset: true,
705
+ polygonOffsetFactor: -1,
706
+ polygonOffsetUnits: -1
707
+ });
708
+ const frontBackGeometry = new THREE2.PlaneGeometry(surfaceWidth, waterDepth);
709
+ const leftRightGeometry = new THREE2.PlaneGeometry(surfaceDepth, waterDepth);
710
+ const sideMeshes = [];
711
+ function addSide(mesh, position, rotation) {
712
+ mesh.position.copy(position);
713
+ if (rotation) {
714
+ mesh.rotation.copy(rotation);
715
+ }
716
+ mesh.renderOrder = waterRenderOrder;
717
+ sideMeshes.push(mesh);
718
+ }
719
+ addSide(
720
+ new THREE2.Mesh(frontBackGeometry, sideMaterial),
721
+ new THREE2.Vector3(0, volumeCenterY, surfaceDepth / 2)
722
+ );
723
+ addSide(
724
+ new THREE2.Mesh(frontBackGeometry, sideMaterial),
725
+ new THREE2.Vector3(0, volumeCenterY, -surfaceDepth / 2),
726
+ new THREE2.Euler(0, Math.PI, 0)
727
+ );
728
+ addSide(
729
+ new THREE2.Mesh(leftRightGeometry, sideMaterial),
730
+ new THREE2.Vector3(surfaceWidth / 2, volumeCenterY, 0),
731
+ new THREE2.Euler(0, -Math.PI / 2, 0)
732
+ );
733
+ addSide(
734
+ new THREE2.Mesh(leftRightGeometry, sideMaterial),
735
+ new THREE2.Vector3(-surfaceWidth / 2, volumeCenterY, 0),
736
+ new THREE2.Euler(0, Math.PI / 2, 0)
737
+ );
738
+ const group = new THREE2.Group();
739
+ group.add(surfaceMesh);
740
+ sideMeshes.forEach((mesh) => group.add(mesh));
741
+ return {
742
+ mesh: group,
743
+ dispose: () => {
744
+ surfaceGeometry.dispose();
745
+ surfaceMaterial.dispose();
746
+ frontBackGeometry.dispose();
747
+ leftRightGeometry.dispose();
748
+ sideMaterial.dispose();
749
+ }
750
+ };
751
+ }
752
+ async function initTerrainViewer(container, dataset, options = {}) {
753
+ if (typeof window === "undefined") {
754
+ let noop2 = function() {
755
+ };
756
+ var noop = noop2;
757
+ return {
758
+ destroy: noop2,
759
+ updateLayers: async () => {
760
+ },
761
+ setInteractiveMode: noop2,
762
+ updateLocations: noop2,
763
+ setFocusedLocation: noop2,
764
+ navigateTo: noop2,
765
+ setHoveredLocation: noop2,
766
+ setCameraOffset: noop2,
767
+ getViewState: () => ({ distance: 1, polar: Math.PI / 3, azimuth: 0 }),
768
+ onCameraMove: () => {
769
+ },
770
+ setTheme: () => {
771
+ },
772
+ setSeaLevel: () => {
773
+ },
774
+ invalidateIconTextures: () => {
775
+ },
776
+ invalidateLayerMasks: () => {
777
+ },
778
+ enableFrameCaptureMode: () => ({ fps: 30 }),
779
+ disableFrameCaptureMode: noop2,
780
+ captureFrame: (frameNumber) => ({ frameNumber, time: 0 })
781
+ };
782
+ }
783
+ const width = container.clientWidth || 720;
784
+ const height = container.clientHeight || 405;
785
+ const disposables = [];
786
+ let currentLifecycleState = "initializing";
787
+ function setLifecycleState(state) {
788
+ currentLifecycleState = state;
789
+ options.onLifecycleChange?.(state);
790
+ }
791
+ const legend = dataset.legend;
792
+ let themeOverrides = options.theme;
793
+ let resolvedTheme = resolveTerrainTheme(dataset.theme, themeOverrides);
794
+ let markerTheme = resolvedTheme.locationMarkers;
795
+ let seaLevel = legend.sea_level ?? SEA_LEVEL_DEFAULT;
796
+ const [rawLegendWidth, rawLegendHeight] = legend.size;
797
+ const safeLegendWidth = Math.max(1, rawLegendWidth || DEFAULT_MAP_WIDTH);
798
+ const safeLegendHeight = Math.max(1, rawLegendHeight || DEFAULT_MAP_HEIGHT);
799
+ const mapWidth = Math.max(1, safeLegendWidth);
800
+ const mapHeight = Math.max(1, safeLegendHeight);
801
+ const mapRatio = safeLegendWidth > 0 ? safeLegendHeight / safeLegendWidth : DEFAULT_MAP_RATIO;
802
+ const terrainWidth = DEFAULT_TERRAIN_WIDTH;
803
+ const terrainDepth = terrainWidth * mapRatio;
804
+ const desiredSegments = THREE2.MathUtils.clamp(
805
+ safeLegendWidth,
806
+ MIN_TERRAIN_SEGMENTS,
807
+ MAX_TERRAIN_SEGMENTS
808
+ );
809
+ const terrainSegmentsX = Math.max(DEFAULT_SEGMENTS_X, Math.round(desiredSegments));
810
+ const terrainSegmentsZ = Math.max(1, Math.round(terrainSegmentsX * mapRatio));
811
+ const terrainDimensions = { width: terrainWidth, depth: terrainDepth };
812
+ const terrainSpan = Math.min(terrainDimensions.width, terrainDimensions.depth);
813
+ function computeMarkerStemHeight() {
814
+ const scaledMin = MARKER_MIN_HEIGHT * MARKER_HEIGHT_SCALE;
815
+ const scaledMax = MARKER_MAX_HEIGHT * MARKER_HEIGHT_SCALE;
816
+ const rawHeight = terrainSpan * MARKER_HEIGHT_RATIO * MARKER_HEIGHT_SCALE;
817
+ return THREE2.MathUtils.clamp(rawHeight, scaledMin, scaledMax);
818
+ }
819
+ const markerStemHeight = computeMarkerStemHeight();
820
+ let terrainHeightRange = { min: FLOOR_Y, max: 0 };
821
+ const baseWidth = terrainWidth + EDGE_RIM * 2;
822
+ const baseDepth = terrainDepth + EDGE_RIM * 2;
823
+ function computeStemRadius(stemTheme) {
824
+ const rawStemScale = stemTheme.scale;
825
+ const stemScale = typeof rawStemScale === "number" && Number.isFinite(rawStemScale) ? Math.max(0, rawStemScale) : DEFAULT_STEM_SCALE;
826
+ const maxStemRadiusCandidate = Math.min(terrainWidth, terrainDepth) * stemScale;
827
+ return Number.isFinite(maxStemRadiusCandidate) ? Math.min(stemTheme.radius, Math.max(0, maxStemRadiusCandidate)) : stemTheme.radius;
828
+ }
829
+ let stemRadius = computeStemRadius(markerTheme.stem);
830
+ const layerImageCache = /* @__PURE__ */ new Map();
831
+ const maskCanvasCache = /* @__PURE__ */ new Map();
832
+ function invalidateLayerMaskCache(paths) {
833
+ if (!paths || paths.length === 0) {
834
+ layerImageCache.clear();
835
+ maskCanvasCache.clear();
836
+ return;
837
+ }
838
+ paths.forEach((path) => {
839
+ layerImageCache.delete(path);
840
+ maskCanvasCache.delete(path);
841
+ });
842
+ }
843
+ function pixelToUV(pixel) {
844
+ const u = THREE2.MathUtils.clamp(pixel.x, 0, mapWidth) / mapWidth;
845
+ const v = THREE2.MathUtils.clamp(pixel.y, 0, mapHeight) / mapHeight;
846
+ return { u, v };
847
+ }
848
+ function uvToPixel(u, v) {
849
+ const x = THREE2.MathUtils.clamp(u * safeLegendWidth, 0, safeLegendWidth);
850
+ const y = THREE2.MathUtils.clamp((1 - v) * safeLegendHeight, 0, safeLegendHeight);
851
+ return { x, y };
852
+ }
853
+ function pixelToWorld(pixel) {
854
+ if (!heightSampler) return null;
855
+ const { u, v } = pixelToUV(pixel);
856
+ return uvToWorld(u, v, heightSampler, currentHeightScale, seaLevel, terrainDimensions);
857
+ }
858
+ const heightScale = options.heightScale ?? HEIGHT_SCALE_DEFAULT;
859
+ let currentHeightScale = heightScale;
860
+ const waterPercent = THREE2.MathUtils.clamp(
861
+ options.waterLevelPercent ?? WATER_PERCENT_DEFAULT,
862
+ 0,
863
+ 100
864
+ );
865
+ const waterPercentNormalized = waterPercent / 100;
866
+ function computeWaterHeight() {
867
+ return THREE2.MathUtils.mapLinear(
868
+ waterPercentNormalized,
869
+ 0,
870
+ 1,
871
+ WATER_MIN * currentHeightScale,
872
+ WATER_MAX * currentHeightScale
873
+ );
874
+ }
875
+ let waterHeight = computeWaterHeight();
876
+ const scene = new THREE2.Scene();
877
+ const camera = new THREE2.PerspectiveCamera(43, width / height, 0.1, 100);
878
+ camera.position.set(-5.2, 3.5, 6);
879
+ const raycaster = new THREE2.Raycaster();
880
+ const pointer = new THREE2.Vector2();
881
+ const downAxis = new THREE2.Vector3(0, -1, 0);
882
+ const surfaceRaycaster = new THREE2.Raycaster();
883
+ const surfaceRayOrigin = new THREE2.Vector3();
884
+ let interactiveEnabled = options.interactive ?? false;
885
+ let heightSampler = null;
886
+ let currentLocations = options.locations ?? [];
887
+ let currentFocusId;
888
+ let viewportWidth = width;
889
+ let viewportHeight = height;
890
+ let viewOffsetPixels = 0;
891
+ const renderer = new THREE2.WebGLRenderer({ antialias: true, alpha: true });
892
+ renderer.toneMapping = THREE2.ACESFilmicToneMapping;
893
+ renderer.toneMappingExposure = 1.08;
894
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
895
+ const hostStyle = window.getComputedStyle(container);
896
+ if (hostStyle.position === "static") {
897
+ container.style.position = "relative";
898
+ }
899
+ renderer.domElement.style.width = "100%";
900
+ renderer.domElement.style.height = "100%";
901
+ renderer.domElement.style.display = "block";
902
+ renderer.domElement.style.position = "absolute";
903
+ renderer.domElement.style.top = "0";
904
+ renderer.domElement.style.right = "0";
905
+ renderer.domElement.style.bottom = "0";
906
+ renderer.domElement.style.left = "0";
907
+ renderer.setSize(width, height, false);
908
+ renderer.shadowMap.enabled = true;
909
+ renderer.shadowMap.type = THREE2.PCFSoftShadowMap;
910
+ container.appendChild(renderer.domElement);
911
+ disposables.push(() => {
912
+ renderer.dispose();
913
+ renderer.domElement.remove();
914
+ });
915
+ const sky = createGradientTexture(renderer);
916
+ if (sky) {
917
+ scene.background = sky.background;
918
+ scene.environment = sky.environment.texture;
919
+ disposables.push(() => {
920
+ scene.background = null;
921
+ scene.environment = null;
922
+ sky.environment.dispose();
923
+ sky.background.dispose();
924
+ });
925
+ }
926
+ const ambientLight = new THREE2.AmbientLight(16249056, 0.55);
927
+ const warmKey = new THREE2.DirectionalLight(16308395, 1.1);
928
+ warmKey.position.set(6, 7, 4);
929
+ warmKey.castShadow = true;
930
+ warmKey.shadow.mapSize.set(1024, 1024);
931
+ warmKey.shadow.camera.near = 1;
932
+ warmKey.shadow.camera.far = 18;
933
+ warmKey.shadow.camera.left = -6;
934
+ warmKey.shadow.camera.right = 6;
935
+ warmKey.shadow.camera.top = 6;
936
+ warmKey.shadow.camera.bottom = -6;
937
+ const coolFill = new THREE2.DirectionalLight(8238335, 0.4);
938
+ coolFill.position.set(-5, 3, -4);
939
+ scene.add(ambientLight, warmKey, coolFill);
940
+ const controls = new OrbitControls(camera, renderer.domElement);
941
+ controls.enableDamping = true;
942
+ controls.enablePan = false;
943
+ controls.maxPolarAngle = Math.PI / 2.2;
944
+ controls.minDistance = 0.5;
945
+ controls.maxDistance = 8.5;
946
+ controls.target.set(0, heightScale * 0.28, 0);
947
+ let lastTime = performance.now();
948
+ const markersGroup = new THREE2.Group();
949
+ scene.add(markersGroup);
950
+ markersGroup.renderOrder = 10;
951
+ const markerResources = [];
952
+ const markerMap = /* @__PURE__ */ new Map();
953
+ const markerInteractiveTargets = [];
954
+ let hoveredLocationId = null;
955
+ const cameraOffset = { target: 0, current: 0 };
956
+ let cameraTween = null;
957
+ let terrain = null;
958
+ function projectWorldToSurface(world) {
959
+ if (!world) return null;
960
+ if (!terrain) return world.clone();
961
+ const originY = (terrainHeightRange.max ?? 0) + 2;
962
+ surfaceRayOrigin.set(world.x, originY, world.z);
963
+ surfaceRaycaster.set(surfaceRayOrigin, downAxis);
964
+ const hit = surfaceRaycaster.intersectObject(terrain, true);
965
+ return hit[0]?.point.clone() ?? world.clone();
966
+ }
967
+ function startCameraTween(endPos, endTarget, duration = 650) {
968
+ cameraTween = {
969
+ startPos: camera.position.clone(),
970
+ endPos,
971
+ startTarget: controls.target.clone(),
972
+ endTarget,
973
+ start: performance.now(),
974
+ duration
975
+ };
976
+ }
977
+ const locationWorldCache = /* @__PURE__ */ new Map();
978
+ let markerGeneration = 0;
979
+ const placementIndicator = new THREE2.Mesh(
980
+ new THREE2.CylinderGeometry(0.04, 0.01, 0.7, 18),
981
+ new THREE2.MeshStandardMaterial({
982
+ color: 16735067,
983
+ transparent: true,
984
+ opacity: 0.8
985
+ })
986
+ );
987
+ placementIndicator.visible = false;
988
+ scene.add(placementIndicator);
989
+ disposables.push(() => {
990
+ placementIndicator.geometry.dispose();
991
+ placementIndicator.material.dispose();
992
+ });
993
+ const loader = new THREE2.TextureLoader();
994
+ const iconTextureCache = /* @__PURE__ */ new Map();
995
+ const iconTexturePromises = /* @__PURE__ */ new Map();
996
+ function invalidateIconCache(paths) {
997
+ if (!paths || paths.length === 0) {
998
+ iconTextureCache.forEach((texture) => texture.dispose());
999
+ iconTextureCache.clear();
1000
+ iconTexturePromises.clear();
1001
+ return;
1002
+ }
1003
+ paths.forEach((path) => {
1004
+ const cached = iconTextureCache.get(path);
1005
+ if (cached) {
1006
+ cached.dispose();
1007
+ iconTextureCache.delete(path);
1008
+ }
1009
+ iconTexturePromises.delete(path);
1010
+ });
1011
+ }
1012
+ function clearMarkerResources() {
1013
+ markerResources.splice(0).forEach(({ spriteMaterials, spriteTextures, stemMaterial, stemGeometry }) => {
1014
+ spriteMaterials.forEach((material) => material.dispose());
1015
+ spriteTextures.forEach((texture) => texture.dispose());
1016
+ stemMaterial?.dispose();
1017
+ stemGeometry?.dispose();
1018
+ });
1019
+ markersGroup.clear();
1020
+ locationWorldCache.clear();
1021
+ markerMap.clear();
1022
+ markerInteractiveTargets.length = 0;
1023
+ }
1024
+ function loadIconTexture(iconPath) {
1025
+ if (iconTextureCache.has(iconPath)) {
1026
+ return Promise.resolve(iconTextureCache.get(iconPath));
1027
+ }
1028
+ if (iconTexturePromises.has(iconPath)) {
1029
+ return iconTexturePromises.get(iconPath);
1030
+ }
1031
+ const pending = Promise.resolve(dataset.resolveAssetUrl(iconPath)).then(
1032
+ (assetUrl) => new Promise((resolve) => {
1033
+ loader.load(
1034
+ assetUrl,
1035
+ (texture) => {
1036
+ texture.colorSpace = THREE2.SRGBColorSpace;
1037
+ iconTextureCache.set(iconPath, texture);
1038
+ resolve(texture);
1039
+ },
1040
+ void 0,
1041
+ (error) => {
1042
+ console.warn("[TerrainViewer] Failed to load icon asset", iconPath, error);
1043
+ resolve(null);
1044
+ }
1045
+ );
1046
+ })
1047
+ ).finally(() => {
1048
+ iconTexturePromises.delete(iconPath);
1049
+ });
1050
+ iconTexturePromises.set(iconPath, pending);
1051
+ return pending;
1052
+ }
1053
+ function formatMarkerGlyph(location) {
1054
+ const rawSource = location.icon && !isAssetIconReference(location.icon) ? location.icon : location.name ?? "";
1055
+ const raw = (rawSource || "?").trim();
1056
+ if (!raw) return "?";
1057
+ const first = raw[0];
1058
+ return /[a-zA-Z0-9]/.test(first) ? first.toUpperCase() : raw;
1059
+ }
1060
+ function updateMarkerVisuals() {
1061
+ const distance = camera.position.distanceTo(controls.target);
1062
+ const lerp = THREE2.MathUtils.lerp(controls.minDistance, controls.maxDistance, distance);
1063
+ const baseScale = lerp / 80;
1064
+ const zoomRange = controls.maxDistance - controls.minDistance;
1065
+ const normalizedZoom = zoomRange > 0 ? THREE2.MathUtils.clamp((distance - controls.minDistance) / zoomRange, 0, 1) : 0;
1066
+ const heightScaleFactor = THREE2.MathUtils.lerp(0.55, 1.1, normalizedZoom);
1067
+ markerMap.forEach(({ sprite, stem, spriteVisuals, stemStates, iconScale, stemBaseHeight, spriteGap }, id) => {
1068
+ const isFocused = currentFocusId === id;
1069
+ const isHovered = hoveredLocationId === id;
1070
+ const emphasis = isFocused ? 1.2 : isHovered ? 1.05 : 1;
1071
+ const scaled = baseScale * (iconScale ?? 1) * emphasis;
1072
+ sprite.scale.set(scaled, scaled, scaled);
1073
+ const visualState = isFocused ? "focus" : isHovered ? "hover" : "default";
1074
+ const nextVisual = spriteVisuals[visualState];
1075
+ if (sprite.material !== nextVisual.material) {
1076
+ sprite.material = nextVisual.material;
1077
+ }
1078
+ const stemMat = stem.material;
1079
+ const stemState = stemStates[visualState];
1080
+ stemMat.opacity = stemState.opacity;
1081
+ stemMat.color.set(stemState.color);
1082
+ const stemWidth = THREE2.MathUtils.clamp(baseScale * 6, 0.12, 0.6);
1083
+ const stemScale = heightScaleFactor;
1084
+ stem.scale.set(stemWidth, stemScale, stemWidth);
1085
+ const currentStemHeight = stemBaseHeight * stemScale;
1086
+ stem.position.y = currentStemHeight / 2;
1087
+ const spriteBase = currentStemHeight + spriteGap + MARKER_LABEL_EXTRA_OFFSET;
1088
+ const spriteHeight = scaled;
1089
+ sprite.position.y = spriteBase + spriteHeight / 2;
1090
+ });
1091
+ }
1092
+ function rebuildRimMesh() {
1093
+ if (!rimMesh) return;
1094
+ scene.remove(rimMesh);
1095
+ rimMesh.geometry.dispose();
1096
+ rimMesh = buildRimMesh(terrainGeometry, FLOOR_Y, rimMaterial);
1097
+ rimMesh.receiveShadow = true;
1098
+ scene.add(rimMesh);
1099
+ }
1100
+ function rebuildOceanMesh() {
1101
+ if (!heightSampler) return;
1102
+ if (ocean) {
1103
+ scene.remove(ocean.mesh);
1104
+ ocean.dispose();
1105
+ }
1106
+ waterHeight = computeWaterHeight();
1107
+ ocean = createOceanMesh(
1108
+ heightMap,
1109
+ heightSampler,
1110
+ currentHeightScale,
1111
+ waterHeight,
1112
+ seaLevel,
1113
+ terrainWidth,
1114
+ terrainDepth
1115
+ );
1116
+ scene.add(ocean.mesh);
1117
+ }
1118
+ function setLocationMarkers(locations, focusedId) {
1119
+ currentLocations = locations;
1120
+ currentFocusId = focusedId;
1121
+ markerGeneration += 1;
1122
+ const runId = markerGeneration;
1123
+ clearMarkerResources();
1124
+ if (!heightSampler || !locations.length) return;
1125
+ const iconPromises = locations.map((location) => {
1126
+ const iconPath = isAssetIconReference(location.icon) ? location.icon : null;
1127
+ return iconPath ? loadIconTexture(iconPath) : Promise.resolve(null);
1128
+ });
1129
+ Promise.all(iconPromises).then((iconTextures) => {
1130
+ if (markerGeneration !== runId) return;
1131
+ locations.forEach((location, i) => {
1132
+ const id = location.id ?? `${location.name ?? "loc"}-${i}`;
1133
+ location.id = id;
1134
+ const { u, v } = pixelToUV(location.pixel);
1135
+ const world = uvToWorld(
1136
+ u,
1137
+ v,
1138
+ heightSampler,
1139
+ currentHeightScale,
1140
+ seaLevel,
1141
+ terrainDimensions
1142
+ );
1143
+ if (!world) return;
1144
+ const surfacePoint = projectWorldToSurface(world);
1145
+ if (!surfacePoint) return;
1146
+ locationWorldCache.set(id, surfacePoint.clone());
1147
+ location.world = { x: surfacePoint.x, y: surfacePoint.y, z: surfacePoint.z };
1148
+ const glyph = formatMarkerGlyph(location);
1149
+ const iconTexture = iconTextures[i] ?? void 0;
1150
+ const spriteVisuals = createMarkerSpriteVisuals(glyph, markerTheme.sprite, {
1151
+ iconTexture,
1152
+ showBorder: location.showBorder !== false
1153
+ });
1154
+ const sprite = new THREE2.Sprite(spriteVisuals.default.material);
1155
+ sprite.userData.locationId = location.id;
1156
+ sprite.renderOrder = 10;
1157
+ const stemHeight = markerStemHeight;
1158
+ const spriteGap = MARKER_SPRITE_GAP;
1159
+ const spriteBaseOffset = stemHeight + spriteGap;
1160
+ const stemStates = createMarkerStemVisuals(markerTheme.stem);
1161
+ const stemMaterial = new THREE2.MeshStandardMaterial({
1162
+ color: stemStates.default.color,
1163
+ transparent: true,
1164
+ opacity: stemStates.default.opacity,
1165
+ flatShading: markerTheme.stem.shape !== "cylinder"
1166
+ });
1167
+ const stemGeometry = createStemGeometry(markerTheme.stem.shape, stemRadius, stemHeight);
1168
+ const stem = new THREE2.Mesh(stemGeometry, stemMaterial);
1169
+ stem.renderOrder = 9;
1170
+ stem.position.set(0, stemHeight / 2, 0);
1171
+ sprite.position.set(0, spriteBaseOffset, 0);
1172
+ stem.userData.locationId = location.id;
1173
+ const container2 = new THREE2.Group();
1174
+ container2.position.copy(surfacePoint);
1175
+ container2.position.y += MARKER_SURFACE_OFFSET;
1176
+ container2.userData.locationId = location.id;
1177
+ container2.add(stem);
1178
+ container2.add(sprite);
1179
+ markersGroup.add(container2);
1180
+ const iconScale = iconTexture ? ICON_SCALE_MULTIPLIER : 1;
1181
+ markerMap.set(location.id, {
1182
+ container: container2,
1183
+ sprite,
1184
+ stem,
1185
+ spriteVisuals,
1186
+ stemStates,
1187
+ iconScale,
1188
+ stemBaseHeight: stemHeight,
1189
+ spriteGap
1190
+ });
1191
+ markerInteractiveTargets.push(sprite, stem);
1192
+ const spriteMaterials = /* @__PURE__ */ new Set();
1193
+ const spriteTextures = /* @__PURE__ */ new Set();
1194
+ Object.values(spriteVisuals).forEach(({ material, texture }) => {
1195
+ spriteMaterials.add(material);
1196
+ spriteTextures.add(texture);
1197
+ });
1198
+ markerResources.push({
1199
+ spriteMaterials,
1200
+ spriteTextures,
1201
+ stemMaterial,
1202
+ stemGeometry: stem.geometry
1203
+ });
1204
+ });
1205
+ if (markerGeneration === runId) {
1206
+ updateMarkerVisuals();
1207
+ }
1208
+ }).catch((error) => console.error("[TerrainViewer] Failed to populate location markers", error));
1209
+ }
1210
+ function applyThemeUpdate(overrides) {
1211
+ themeOverrides = overrides;
1212
+ resolvedTheme = resolveTerrainTheme(dataset.theme, themeOverrides);
1213
+ markerTheme = resolvedTheme.locationMarkers;
1214
+ stemRadius = computeStemRadius(markerTheme.stem);
1215
+ setLocationMarkers(currentLocations, currentFocusId);
1216
+ }
1217
+ function applySeaLevelUpdate(nextSeaLevel) {
1218
+ if (!heightSampler) {
1219
+ return;
1220
+ }
1221
+ seaLevel = nextSeaLevel;
1222
+ legend.sea_level = nextSeaLevel;
1223
+ dataset.legend.sea_level = nextSeaLevel;
1224
+ const stats = applyHeightField(terrainGeometry, heightSampler, {
1225
+ seaLevel,
1226
+ heightScale: currentHeightScale
1227
+ });
1228
+ terrainHeightRange = { min: stats.minY, max: stats.maxY };
1229
+ terrainGeometry.attributes.position.needsUpdate = true;
1230
+ terrainGeometry.computeVertexNormals();
1231
+ rebuildRimMesh();
1232
+ rebuildOceanMesh();
1233
+ setLocationMarkers(currentLocations, currentFocusId);
1234
+ }
1235
+ setLifecycleState("loading-textures");
1236
+ const [heightMapSource, topoMapSource] = await Promise.all([
1237
+ Promise.resolve(dataset.getHeightMapUrl()),
1238
+ Promise.resolve(dataset.getTopologyMapUrl())
1239
+ ]);
1240
+ const [heightMap, topoTexture] = await Promise.all([
1241
+ new Promise((resolve, reject) => {
1242
+ loader.load(heightMapSource, resolve, void 0, reject);
1243
+ }),
1244
+ new Promise((resolve, reject) => {
1245
+ loader.load(topoMapSource, resolve, void 0, reject);
1246
+ })
1247
+ ]);
1248
+ heightMap.wrapS = heightMap.wrapT = THREE2.ClampToEdgeWrapping;
1249
+ topoTexture.wrapS = topoTexture.wrapT = THREE2.ClampToEdgeWrapping;
1250
+ topoTexture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 8);
1251
+ setLifecycleState("building-geometry");
1252
+ const sampler = createHeightSampler(heightMap);
1253
+ if (!sampler) throw new Error("Unable to read heightmap data");
1254
+ heightSampler = sampler;
1255
+ const terrainGeometry = new THREE2.PlaneGeometry(
1256
+ terrainWidth,
1257
+ terrainDepth,
1258
+ terrainSegmentsX,
1259
+ terrainSegmentsZ
1260
+ );
1261
+ terrainGeometry.rotateX(-Math.PI / 2);
1262
+ const heightStats = applyHeightField(terrainGeometry, sampler, { seaLevel, heightScale });
1263
+ terrainHeightRange = { min: heightStats.minY, max: heightStats.maxY };
1264
+ let legendTexture = await composeLegendTexture(
1265
+ legend,
1266
+ dataset.resolveAssetUrl,
1267
+ layerImageCache,
1268
+ maskCanvasCache,
1269
+ options.layers
1270
+ );
1271
+ const terrainMaterial = new THREE2.MeshStandardMaterial({
1272
+ map: legendTexture ?? topoTexture,
1273
+ roughness: 0.6,
1274
+ metalness: 0.18,
1275
+ envMapIntensity: 0.45,
1276
+ color: new THREE2.Color(16315885)
1277
+ });
1278
+ terrain = new THREE2.Mesh(terrainGeometry, terrainMaterial);
1279
+ terrain.castShadow = true;
1280
+ terrain.receiveShadow = true;
1281
+ scene.add(terrain);
1282
+ if (currentLocations.length) {
1283
+ setLocationMarkers(currentLocations, currentFocusId);
1284
+ }
1285
+ const base = createBaseSlice(baseWidth, baseDepth);
1286
+ scene.add(base.mesh);
1287
+ const rimMaterial = new THREE2.MeshStandardMaterial({
1288
+ color: 1315359,
1289
+ roughness: 0.5,
1290
+ metalness: 0.2,
1291
+ side: THREE2.DoubleSide
1292
+ });
1293
+ let rimMesh = buildRimMesh(terrainGeometry, FLOOR_Y, rimMaterial);
1294
+ rimMesh.receiveShadow = true;
1295
+ scene.add(rimMesh);
1296
+ const createOcean = () => createOceanMesh(
1297
+ heightMap,
1298
+ sampler,
1299
+ heightScale,
1300
+ waterHeight,
1301
+ seaLevel,
1302
+ terrainWidth,
1303
+ terrainDepth
1304
+ );
1305
+ let ocean = createOcean();
1306
+ scene.add(ocean.mesh);
1307
+ disposables.push(() => {
1308
+ terrainGeometry.dispose();
1309
+ terrainMaterial.dispose();
1310
+ base.dispose();
1311
+ rimMesh.geometry.dispose();
1312
+ rimMaterial.dispose();
1313
+ ocean.dispose();
1314
+ heightMap.dispose();
1315
+ topoTexture.dispose();
1316
+ legendTexture?.dispose();
1317
+ });
1318
+ disposables.push(() => {
1319
+ iconTextureCache.forEach((texture) => texture.dispose());
1320
+ iconTextureCache.clear();
1321
+ iconTexturePromises.clear();
1322
+ });
1323
+ function applyViewOffset() {
1324
+ if (Math.abs(viewOffsetPixels) < 0.5) {
1325
+ if (camera.view?.enabled) {
1326
+ camera.clearViewOffset();
1327
+ camera.updateProjectionMatrix();
1328
+ }
1329
+ return;
1330
+ }
1331
+ const shiftPixels = Math.round(viewOffsetPixels);
1332
+ camera.setViewOffset(
1333
+ viewportWidth,
1334
+ viewportHeight,
1335
+ shiftPixels,
1336
+ 0,
1337
+ viewportWidth,
1338
+ viewportHeight
1339
+ );
1340
+ camera.updateProjectionMatrix();
1341
+ }
1342
+ let animationFrame = 0;
1343
+ let frameCount = 0;
1344
+ let firstRenderComplete = false;
1345
+ const frameTimings = [];
1346
+ const STABILITY_FRAME_COUNT = 10;
1347
+ const STABILITY_THRESHOLD_MS = 50;
1348
+ const STABILITY_TIMEOUT_MS = 2e3;
1349
+ let stabilizingStartTime = null;
1350
+ let frameCaptureMode = false;
1351
+ let manualFrameTime = 0;
1352
+ let frameCaptureStartTime = 0;
1353
+ function animate() {
1354
+ const now = frameCaptureMode ? manualFrameTime : performance.now();
1355
+ const delta = frameCaptureMode ? 1 / captureFps : Math.min((now - lastTime) / 1e3, 0.15);
1356
+ const frameDuration = now - lastTime;
1357
+ lastTime = now;
1358
+ if (cameraTween) {
1359
+ const progress = Math.min((now - cameraTween.start) / cameraTween.duration, 1);
1360
+ const eased = easeInOut(progress);
1361
+ camera.position.copy(cameraTween.startPos).lerp(cameraTween.endPos, eased);
1362
+ controls.target.copy(cameraTween.startTarget).lerp(cameraTween.endTarget, eased);
1363
+ if (progress >= 1) cameraTween = null;
1364
+ }
1365
+ cameraOffset.current = THREE2.MathUtils.damp(
1366
+ cameraOffset.current,
1367
+ cameraOffset.target,
1368
+ 6,
1369
+ delta
1370
+ );
1371
+ const shiftTarget = cameraOffset.current * viewportWidth * 0.5;
1372
+ viewOffsetPixels = THREE2.MathUtils.damp(viewOffsetPixels, shiftTarget, 6, delta);
1373
+ applyViewOffset();
1374
+ controls.update();
1375
+ updateMarkerVisuals();
1376
+ renderer.render(scene, camera);
1377
+ frameCount++;
1378
+ if (!firstRenderComplete && frameCount === 1) {
1379
+ setLifecycleState("first-render");
1380
+ firstRenderComplete = true;
1381
+ }
1382
+ if (currentLifecycleState === "first-render" || currentLifecycleState === "stabilizing") {
1383
+ frameTimings.push(frameDuration);
1384
+ if (frameTimings.length > STABILITY_FRAME_COUNT) {
1385
+ frameTimings.shift();
1386
+ }
1387
+ if (frameTimings.length === STABILITY_FRAME_COUNT) {
1388
+ const avgFrameTime = frameTimings.reduce((a, b) => a + b, 0) / STABILITY_FRAME_COUNT;
1389
+ if (avgFrameTime < STABILITY_THRESHOLD_MS) {
1390
+ setLifecycleState("ready");
1391
+ stabilizingStartTime = null;
1392
+ } else if (currentLifecycleState === "first-render") {
1393
+ setLifecycleState("stabilizing");
1394
+ stabilizingStartTime = now;
1395
+ }
1396
+ }
1397
+ if (currentLifecycleState === "stabilizing" && stabilizingStartTime !== null) {
1398
+ const stabilizingDuration = now - stabilizingStartTime;
1399
+ if (stabilizingDuration > STABILITY_TIMEOUT_MS) {
1400
+ console.warn(`[terrainViewer] Framerate did not stabilize after ${STABILITY_TIMEOUT_MS}ms, forcing ready state`);
1401
+ setLifecycleState("ready");
1402
+ stabilizingStartTime = null;
1403
+ }
1404
+ }
1405
+ }
1406
+ if (!frameCaptureMode) {
1407
+ animationFrame = window.requestAnimationFrame(animate);
1408
+ }
1409
+ }
1410
+ animate();
1411
+ options.onReady?.();
1412
+ const resizeObserver = new ResizeObserver(() => {
1413
+ const { clientWidth, clientHeight } = container;
1414
+ if (!clientWidth || !clientHeight) return;
1415
+ viewportWidth = clientWidth;
1416
+ viewportHeight = clientHeight;
1417
+ const shiftTarget = cameraOffset.current * viewportWidth * 0.5;
1418
+ viewOffsetPixels = shiftTarget;
1419
+ camera.aspect = clientWidth / clientHeight;
1420
+ camera.updateProjectionMatrix();
1421
+ renderer.setSize(clientWidth, clientHeight, false);
1422
+ applyViewOffset();
1423
+ });
1424
+ resizeObserver.observe(container);
1425
+ function setPointerFromEvent(event) {
1426
+ const rect = renderer.domElement.getBoundingClientRect();
1427
+ const x = (event.clientX - rect.left) / rect.width * 2 - 1;
1428
+ const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
1429
+ pointer.set(x, y);
1430
+ raycaster.setFromCamera(pointer, camera);
1431
+ }
1432
+ function intersectTerrain() {
1433
+ if (!terrain) return null;
1434
+ const intersections = raycaster.intersectObject(terrain, true);
1435
+ return intersections[0] ?? null;
1436
+ }
1437
+ function pickMarkerId() {
1438
+ if (!markerInteractiveTargets.length) return null;
1439
+ const intersections = raycaster.intersectObjects(markerInteractiveTargets, true);
1440
+ const hit = intersections.find((entry) => entry.object.userData.locationId);
1441
+ return hit?.object.userData.locationId || null;
1442
+ }
1443
+ function updateHoverState() {
1444
+ const markerId = pickMarkerId();
1445
+ if (markerId !== hoveredLocationId) {
1446
+ hoveredLocationId = markerId;
1447
+ options.onLocationHover?.(markerId);
1448
+ updateMarkerVisuals();
1449
+ }
1450
+ }
1451
+ function updatePlacementIndicator() {
1452
+ if (!interactiveEnabled) {
1453
+ placementIndicator.visible = false;
1454
+ return;
1455
+ }
1456
+ const hit = intersectTerrain();
1457
+ if (!hit) {
1458
+ placementIndicator.visible = false;
1459
+ return;
1460
+ }
1461
+ placementIndicator.visible = true;
1462
+ placementIndicator.position.copy(hit.point).setY(hit.point.y + 0.2);
1463
+ }
1464
+ function handlePointerMove(event) {
1465
+ setPointerFromEvent(event);
1466
+ updateHoverState();
1467
+ updatePlacementIndicator();
1468
+ }
1469
+ function handlePointerDown(event) {
1470
+ if (event.button !== 0) return;
1471
+ setPointerFromEvent(event);
1472
+ if (interactiveEnabled && options.onLocationPick) {
1473
+ const hit = intersectTerrain();
1474
+ if (!hit || !hit.uv) return;
1475
+ const u = hit.uv.x;
1476
+ const v = hit.uv.y;
1477
+ const pixel = uvToPixel(u, v);
1478
+ options.onLocationPick?.({
1479
+ pixel,
1480
+ uv: { u, v },
1481
+ world: { x: hit.point.x, y: hit.point.y, z: hit.point.z }
1482
+ });
1483
+ return;
1484
+ }
1485
+ const markerId = pickMarkerId();
1486
+ if (markerId && options.onLocationClick) {
1487
+ options.onLocationClick(markerId);
1488
+ }
1489
+ }
1490
+ renderer.domElement.addEventListener("pointermove", handlePointerMove);
1491
+ renderer.domElement.addEventListener("pointerdown", handlePointerDown);
1492
+ function handleDoubleClick(event) {
1493
+ event.preventDefault();
1494
+ event.stopPropagation();
1495
+ setPointerFromEvent(event);
1496
+ const hit = intersectTerrain();
1497
+ if (!hit) return;
1498
+ const worldPoint = hit.point.clone();
1499
+ const pixel = hit.uv ? uvToPixel(hit.uv.x, hit.uv.y) : { x: worldPoint.x, y: worldPoint.z };
1500
+ navigateToLocation({
1501
+ pixel,
1502
+ world: worldPoint
1503
+ });
1504
+ }
1505
+ renderer.domElement.addEventListener("dblclick", handleDoubleClick);
1506
+ disposables.push(
1507
+ () => renderer.domElement.removeEventListener("pointerdown", handlePointerDown)
1508
+ );
1509
+ disposables.push(
1510
+ () => renderer.domElement.removeEventListener("pointermove", handlePointerMove)
1511
+ );
1512
+ disposables.push(
1513
+ () => renderer.domElement.removeEventListener("dblclick", handleDoubleClick)
1514
+ );
1515
+ function setInteractiveMode(enabled) {
1516
+ interactiveEnabled = enabled;
1517
+ if (enabled) {
1518
+ renderer.domElement.classList.add("terrain-pointer-active");
1519
+ } else {
1520
+ renderer.domElement.classList.remove("terrain-pointer-active");
1521
+ placementIndicator.visible = false;
1522
+ }
1523
+ }
1524
+ async function updateLayers(state) {
1525
+ try {
1526
+ const newTexture = await composeLegendTexture(
1527
+ legend,
1528
+ dataset.resolveAssetUrl,
1529
+ layerImageCache,
1530
+ maskCanvasCache,
1531
+ state
1532
+ );
1533
+ if (!newTexture) return;
1534
+ legendTexture?.dispose();
1535
+ legendTexture = newTexture;
1536
+ terrainMaterial.map = newTexture;
1537
+ terrainMaterial.needsUpdate = true;
1538
+ } catch (err) {
1539
+ console.error("Failed to update Wynnal layers", err);
1540
+ }
1541
+ }
1542
+ if (options.layers) await updateLayers(options.layers);
1543
+ if (options.locations?.length) setLocationMarkers(options.locations);
1544
+ if (interactiveEnabled) setInteractiveMode(true);
1545
+ if (options.cameraView) {
1546
+ const fallbackPixel = {
1547
+ x: options.cameraView.targetPixel?.x ?? mapWidth / 2,
1548
+ y: options.cameraView.targetPixel?.y ?? mapHeight / 2
1549
+ };
1550
+ navigateToLocation({
1551
+ pixel: fallbackPixel,
1552
+ view: options.cameraView,
1553
+ instant: true
1554
+ });
1555
+ }
1556
+ function normalizeWorld(value) {
1557
+ if (!value) return null;
1558
+ if (value instanceof THREE2.Vector3) return value.clone();
1559
+ return new THREE2.Vector3(value.x, value.y, value.z);
1560
+ }
1561
+ function navigateToLocation(payload) {
1562
+ const { pixel, locationId, world: worldOverride, view, instant } = payload;
1563
+ let world = normalizeWorld(worldOverride) || locationId && locationWorldCache.get(locationId)?.clone() || pixelToWorld(pixel);
1564
+ world = projectWorldToSurface(world);
1565
+ if (!world) return;
1566
+ const distance = view?.distance ?? Math.max(camera.position.distanceTo(controls.target), 0.1);
1567
+ const polar = view?.polar ?? controls.getPolarAngle();
1568
+ const azimuth = view?.azimuth ?? controls.getAzimuthalAngle();
1569
+ const spherical = new THREE2.Spherical(distance, polar, azimuth);
1570
+ const orbitOffset = new THREE2.Vector3().setFromSpherical(spherical);
1571
+ const baseTarget = world.clone();
1572
+ const basePos = world.clone().add(orbitOffset);
1573
+ const endTarget = baseTarget.clone();
1574
+ const endPos = basePos.clone();
1575
+ cameraOffset.current = cameraOffset.target;
1576
+ const duration = instant ? 0 : 650;
1577
+ startCameraTween(endPos, endTarget, duration);
1578
+ if (locationId) currentFocusId = locationId;
1579
+ }
1580
+ let captureFps = 30;
1581
+ return {
1582
+ destroy: () => {
1583
+ window.cancelAnimationFrame(animationFrame);
1584
+ resizeObserver.disconnect();
1585
+ controls.dispose();
1586
+ disposables.forEach((dispose) => dispose());
1587
+ dataset.cleanup?.();
1588
+ },
1589
+ updateLayers,
1590
+ setInteractiveMode,
1591
+ updateLocations: (locations, focusedId) => {
1592
+ setLocationMarkers(locations, focusedId);
1593
+ },
1594
+ setFocusedLocation: (locationId) => {
1595
+ const nextId = locationId ?? void 0;
1596
+ if (currentFocusId === nextId) return;
1597
+ currentFocusId = nextId;
1598
+ updateMarkerVisuals();
1599
+ },
1600
+ navigateTo: navigateToLocation,
1601
+ setHoveredLocation: (id) => {
1602
+ hoveredLocationId = id;
1603
+ updateMarkerVisuals();
1604
+ },
1605
+ setCameraOffset: (offset, focusId) => {
1606
+ cameraOffset.target = THREE2.MathUtils.clamp(offset, -0.45, 0.45);
1607
+ const targetId = focusId ?? currentFocusId;
1608
+ if (targetId) {
1609
+ const loc = currentLocations.find((item) => item.id === targetId);
1610
+ if (loc) {
1611
+ navigateToLocation({
1612
+ pixel: loc.pixel,
1613
+ locationId: targetId,
1614
+ view: loc.view,
1615
+ instant: true
1616
+ });
1617
+ }
1618
+ }
1619
+ },
1620
+ onCameraMove: (callback) => {
1621
+ const handler = () => {
1622
+ const distance = Math.max(camera.position.distanceTo(controls.target), 0.1);
1623
+ const targetPixel = worldToPixel(controls.target, terrainDimensions, {
1624
+ width: mapWidth,
1625
+ height: mapHeight
1626
+ });
1627
+ callback({
1628
+ distance,
1629
+ polar: controls.getPolarAngle(),
1630
+ azimuth: controls.getAzimuthalAngle(),
1631
+ targetPixel
1632
+ });
1633
+ };
1634
+ controls.addEventListener("change", handler);
1635
+ return () => {
1636
+ controls.removeEventListener("change", handler);
1637
+ };
1638
+ },
1639
+ getViewState: () => {
1640
+ const distance = Math.max(camera.position.distanceTo(controls.target), 0.1);
1641
+ const targetPixel = worldToPixel(controls.target, terrainDimensions, {
1642
+ width: mapWidth,
1643
+ height: mapHeight
1644
+ });
1645
+ return {
1646
+ distance,
1647
+ polar: controls.getPolarAngle(),
1648
+ azimuth: controls.getAzimuthalAngle(),
1649
+ targetPixel
1650
+ };
1651
+ },
1652
+ setTheme: applyThemeUpdate,
1653
+ setSeaLevel: applySeaLevelUpdate,
1654
+ invalidateIconTextures: (paths) => {
1655
+ invalidateIconCache(paths);
1656
+ setLocationMarkers(currentLocations, currentFocusId);
1657
+ },
1658
+ invalidateLayerMasks: (paths) => {
1659
+ invalidateLayerMaskCache(paths);
1660
+ },
1661
+ // Frame capture API for deterministic rendering
1662
+ enableFrameCaptureMode: (fps = 30) => {
1663
+ captureFps = fps;
1664
+ frameCaptureMode = true;
1665
+ frameCaptureStartTime = performance.now();
1666
+ manualFrameTime = frameCaptureStartTime;
1667
+ lastTime = frameCaptureStartTime;
1668
+ return { fps };
1669
+ },
1670
+ disableFrameCaptureMode: () => {
1671
+ frameCaptureMode = false;
1672
+ animationFrame = window.requestAnimationFrame(animate);
1673
+ },
1674
+ captureFrame: (frameNumber, fps = 30) => {
1675
+ if (!frameCaptureMode) {
1676
+ throw new Error("Frame capture mode is not enabled. Call enableFrameCaptureMode() first.");
1677
+ }
1678
+ manualFrameTime = frameCaptureStartTime + frameNumber / fps * 1e3;
1679
+ animate();
1680
+ return { frameNumber, time: manualFrameTime };
1681
+ }
1682
+ };
1683
+ }
1684
+
1685
+ // src/loadWynArchive.ts
1686
+ import JSZip from "jszip";
1687
+ function ensureFile(zip, path) {
1688
+ const file = zip.file(path);
1689
+ if (!file) {
1690
+ throw new Error(`Missing required file in WYN archive: ${path}`);
1691
+ }
1692
+ return file;
1693
+ }
1694
+ function createObjectUrlResolver(zip) {
1695
+ const cache = /* @__PURE__ */ new Map();
1696
+ const allocated = /* @__PURE__ */ new Set();
1697
+ function getUrl(path) {
1698
+ if (cache.has(path)) return cache.get(path);
1699
+ const file = zip.file(path);
1700
+ if (!file) {
1701
+ return Promise.reject(new Error(`Asset not found in WYN archive: ${path}`));
1702
+ }
1703
+ const promise = file.async("blob").then((blob) => {
1704
+ const url = URL.createObjectURL(blob);
1705
+ allocated.add(url);
1706
+ return url;
1707
+ });
1708
+ cache.set(path, promise);
1709
+ return promise;
1710
+ }
1711
+ function cleanup() {
1712
+ allocated.forEach((url) => URL.revokeObjectURL(url));
1713
+ allocated.clear();
1714
+ cache.clear();
1715
+ }
1716
+ return { getUrl, cleanup };
1717
+ }
1718
+ function parseContentLength(header) {
1719
+ if (!header) return void 0;
1720
+ const parsed = Number(header);
1721
+ return Number.isFinite(parsed) ? parsed : void 0;
1722
+ }
1723
+ async function readResponseWithProgress(response, onProgress) {
1724
+ if (!response.body || typeof response.body.getReader !== "function") {
1725
+ const buffer = await response.arrayBuffer();
1726
+ if (onProgress) {
1727
+ const totalBytes2 = buffer.byteLength;
1728
+ onProgress({ type: "network-download", loadedBytes: totalBytes2, totalBytes: totalBytes2 });
1729
+ }
1730
+ return buffer;
1731
+ }
1732
+ const reader = response.body.getReader();
1733
+ const totalBytes = parseContentLength(response.headers.get("content-length"));
1734
+ const chunks = [];
1735
+ let loadedBytes = 0;
1736
+ while (true) {
1737
+ const { done, value } = await reader.read();
1738
+ if (done) break;
1739
+ if (value) {
1740
+ chunks.push(value);
1741
+ loadedBytes += value.length;
1742
+ onProgress?.({ type: "network-download", loadedBytes, totalBytes });
1743
+ }
1744
+ }
1745
+ reader.releaseLock();
1746
+ if (!chunks.length) {
1747
+ onProgress?.({ type: "network-download", loadedBytes, totalBytes });
1748
+ }
1749
+ const merged = new Uint8Array(loadedBytes);
1750
+ let offset = 0;
1751
+ for (const chunk of chunks) {
1752
+ merged.set(chunk, offset);
1753
+ offset += chunk.length;
1754
+ }
1755
+ return merged.buffer;
1756
+ }
1757
+ async function readFileWithProgress(file, onProgress) {
1758
+ if (typeof FileReader === "undefined") {
1759
+ const buffer = await file.arrayBuffer();
1760
+ onProgress?.({ type: "file-read", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
1761
+ return buffer;
1762
+ }
1763
+ return new Promise((resolve, reject) => {
1764
+ const reader = new FileReader();
1765
+ reader.addEventListener("error", () => {
1766
+ reject(reader.error ?? new Error("Failed to read file."));
1767
+ });
1768
+ reader.addEventListener("abort", () => {
1769
+ reject(new Error("File read aborted."));
1770
+ });
1771
+ reader.addEventListener("load", () => {
1772
+ onProgress?.({ type: "file-read", loadedBytes: file.size, totalBytes: file.size });
1773
+ resolve(reader.result);
1774
+ });
1775
+ if (onProgress) {
1776
+ reader.addEventListener("progress", (event) => {
1777
+ const totalBytes = event.lengthComputable ? event.total : file.size;
1778
+ onProgress({
1779
+ type: "file-read",
1780
+ loadedBytes: event.loaded,
1781
+ totalBytes
1782
+ });
1783
+ });
1784
+ }
1785
+ reader.readAsArrayBuffer(file);
1786
+ });
1787
+ }
1788
+ function guessMimeType(path) {
1789
+ const extension = path.split(".").pop()?.toLowerCase();
1790
+ if (!extension) return void 0;
1791
+ if (extension === "png") return "image/png";
1792
+ if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
1793
+ if (extension === "webp") return "image/webp";
1794
+ if (extension === "gif") return "image/gif";
1795
+ if (extension === "json") return "application/json";
1796
+ return void 0;
1797
+ }
1798
+ var SKIP_FILE_TABLE = /* @__PURE__ */ new Set(["legend.json", "locations.json", "theme.json", "metadata.json"]);
1799
+ async function extractProjectFiles(zip) {
1800
+ const entries = [];
1801
+ const fileEntries = Object.values(zip.files);
1802
+ for (const entry of fileEntries) {
1803
+ if (entry.dir) continue;
1804
+ if (!entry.name) continue;
1805
+ if (entry.name.startsWith("__MACOSX/")) continue;
1806
+ if (SKIP_FILE_TABLE.has(entry.name)) continue;
1807
+ const path = entry.name;
1808
+ const data = await entry.async("arraybuffer");
1809
+ entries.push({
1810
+ path,
1811
+ data,
1812
+ type: guessMimeType(path),
1813
+ lastModified: entry.date?.getTime(),
1814
+ sourceFileName: path.split("/").pop()
1815
+ });
1816
+ }
1817
+ return entries;
1818
+ }
1819
+ async function parseWynZip(zip, options = {}) {
1820
+ const legendFile = ensureFile(zip, "legend.json");
1821
+ const legendRaw = await legendFile.async("string");
1822
+ const legend = JSON.parse(legendRaw);
1823
+ const locationEntry = zip.file("locations.json");
1824
+ let locations;
1825
+ if (locationEntry) {
1826
+ const contents = await locationEntry.async("string");
1827
+ locations = JSON.parse(contents);
1828
+ }
1829
+ const themeEntry = zip.file("theme.json");
1830
+ let themeOverrides;
1831
+ if (themeEntry) {
1832
+ const contents = await themeEntry.async("string");
1833
+ themeOverrides = JSON.parse(contents);
1834
+ }
1835
+ const metadataEntry = zip.file("metadata.json");
1836
+ let metadata;
1837
+ if (metadataEntry) {
1838
+ const contents = await metadataEntry.async("string");
1839
+ metadata = JSON.parse(contents);
1840
+ }
1841
+ const { getUrl, cleanup } = createObjectUrlResolver(zip);
1842
+ const heightMapPath = legend.heightmap;
1843
+ const topologyPath = legend.topology ?? legend.heightmap;
1844
+ const dataset = {
1845
+ legend,
1846
+ getHeightMapUrl: () => getUrl(heightMapPath),
1847
+ getTopologyMapUrl: () => getUrl(topologyPath),
1848
+ resolveAssetUrl: (path) => getUrl(path),
1849
+ cleanup,
1850
+ theme: themeOverrides
1851
+ };
1852
+ const files = options.includeFiles ? await extractProjectFiles(zip) : void 0;
1853
+ return { dataset, legend, locations, files, metadata };
1854
+ }
1855
+ async function loadWynArchive(url, options = {}) {
1856
+ const response = await fetch(url);
1857
+ if (!response.ok) {
1858
+ throw new Error(`Failed to fetch WYN file (${response.status} ${response.statusText})`);
1859
+ }
1860
+ const arrayBuffer = await readResponseWithProgress(response, options.onProgress);
1861
+ const zip = await JSZip.loadAsync(arrayBuffer);
1862
+ return parseWynZip(zip, options);
1863
+ }
1864
+ async function loadWynArchiveFromArrayBuffer(data, options = {}) {
1865
+ const zip = await JSZip.loadAsync(data);
1866
+ return parseWynZip(zip, options);
1867
+ }
1868
+ async function loadWynArchiveFromFile(file, options = {}) {
1869
+ const buffer = await readFileWithProgress(file, options.onProgress);
1870
+ return loadWynArchiveFromArrayBuffer(buffer, options);
1871
+ }
1872
+
1873
+ // src/viewerHost.ts
1874
+ var HOST_STYLE_ID = "ctw-viewer-host-style";
1875
+ var HOST_CSS = `
1876
+ .viewer-popout {
1877
+ position: fixed;
1878
+ inset: 0;
1879
+ background: rgba(5, 7, 13, 0.85);
1880
+ backdrop-filter: blur(6px);
1881
+ display: none;
1882
+ align-items: center;
1883
+ justify-content: center;
1884
+ padding: 2rem;
1885
+ z-index: 999;
1886
+ }
1887
+
1888
+ .viewer-popout.is-open {
1889
+ display: flex;
1890
+ }
1891
+
1892
+ .viewer-popout.is-fullscreen {
1893
+ padding: 0;
1894
+ }
1895
+
1896
+ .viewer-popout__chrome {
1897
+ width: min(1200px, 100%);
1898
+ height: min(90vh, 760px);
1899
+ background: #05070d;
1900
+ border-radius: 18px;
1901
+ border: 1px solid rgba(255, 255, 255, 0.15);
1902
+ box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
1903
+ display: flex;
1904
+ flex-direction: column;
1905
+ overflow: hidden;
1906
+ }
1907
+
1908
+ .viewer-popout.is-fullscreen .viewer-popout__chrome {
1909
+ width: 100%;
1910
+ height: 100%;
1911
+ border-radius: 0;
1912
+ border: none;
1913
+ }
1914
+
1915
+ .viewer-popout__header {
1916
+ padding: 1rem 1.25rem;
1917
+ display: flex;
1918
+ justify-content: space-between;
1919
+ align-items: center;
1920
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
1921
+ font-family: 'Inter', 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', system-ui, sans-serif;
1922
+ color: #f6e7c3;
1923
+ }
1924
+
1925
+ .viewer-popout__header .label {
1926
+ margin: 0;
1927
+ font-weight: 600;
1928
+ }
1929
+
1930
+ .viewer-popout__header .hint {
1931
+ margin: 0;
1932
+ font-size: 0.85rem;
1933
+ color: #b8b2a3;
1934
+ }
1935
+
1936
+ .viewer-popout__actions {
1937
+ display: flex;
1938
+ gap: 0.5rem;
1939
+ }
1940
+
1941
+ .viewer-popout__slot {
1942
+ flex: 1;
1943
+ padding: 1rem;
1944
+ }
1945
+
1946
+ .viewer-popout.is-fullscreen .viewer-popout__slot {
1947
+ padding: 0;
1948
+ }
1949
+
1950
+ .chip-button {
1951
+ background: rgba(255, 255, 255, 0.06);
1952
+ border: 1px solid rgba(255, 255, 255, 0.1);
1953
+ color: #f6e7c3;
1954
+ border-radius: 999px;
1955
+ padding: 0.45rem 1rem;
1956
+ cursor: pointer;
1957
+ font-size: 0.85rem;
1958
+ font-family: inherit;
1959
+ }
1960
+
1961
+ .chip-button:hover {
1962
+ border-color: rgba(223, 195, 135, 0.7);
1963
+ }
1964
+ `;
1965
+ var noopHandle = {
1966
+ openPopout: () => {
1967
+ },
1968
+ closePopout: () => {
1969
+ },
1970
+ toggleFullscreen: () => Promise.resolve(),
1971
+ getMode: () => "embed",
1972
+ destroy: () => {
1973
+ }
1974
+ };
1975
+ function ensureHostStyles(doc) {
1976
+ if (doc.getElementById(HOST_STYLE_ID)) return;
1977
+ const style = doc.createElement("style");
1978
+ style.id = HOST_STYLE_ID;
1979
+ style.textContent = HOST_CSS;
1980
+ doc.head.appendChild(style);
1981
+ }
1982
+ function createTerrainViewerHost(options) {
1983
+ if (typeof window === "undefined") return noopHandle;
1984
+ const { viewerElement, embedTarget } = options;
1985
+ const doc = options.documentRoot ?? document;
1986
+ if (!viewerElement || !embedTarget) return noopHandle;
1987
+ ensureHostStyles(doc);
1988
+ let mode = "embed";
1989
+ let popoutOpen = false;
1990
+ const overlay = doc.createElement("div");
1991
+ overlay.className = "viewer-popout";
1992
+ overlay.setAttribute("aria-hidden", "true");
1993
+ const chrome = doc.createElement("div");
1994
+ chrome.className = "viewer-popout__chrome";
1995
+ overlay.appendChild(chrome);
1996
+ const popoutSlot = doc.createElement("div");
1997
+ popoutSlot.className = "viewer-popout__slot";
1998
+ chrome.appendChild(popoutSlot);
1999
+ doc.body.appendChild(overlay);
2000
+ embedTarget.appendChild(viewerElement);
2001
+ function syncOverlayState() {
2002
+ overlay.classList.toggle("is-fullscreen", mode === "fullscreen");
2003
+ }
2004
+ function setMode(newMode) {
2005
+ if (mode === newMode) return;
2006
+ mode = newMode;
2007
+ syncOverlayState();
2008
+ options.onModeChange?.(mode);
2009
+ }
2010
+ function moveViewer(target) {
2011
+ target.appendChild(viewerElement);
2012
+ }
2013
+ function openPopout() {
2014
+ if (popoutOpen) return;
2015
+ popoutOpen = true;
2016
+ overlay.classList.add("is-open");
2017
+ overlay.setAttribute("aria-hidden", "false");
2018
+ moveViewer(popoutSlot);
2019
+ setMode("popout");
2020
+ }
2021
+ function closePopout() {
2022
+ if (!popoutOpen) return;
2023
+ popoutOpen = false;
2024
+ overlay.classList.remove("is-open");
2025
+ overlay.setAttribute("aria-hidden", "true");
2026
+ moveViewer(embedTarget);
2027
+ if (doc.fullscreenElement === overlay) {
2028
+ void doc.exitFullscreen();
2029
+ }
2030
+ setMode("embed");
2031
+ }
2032
+ async function toggleFullscreen() {
2033
+ if (!popoutOpen) return;
2034
+ if (doc.fullscreenElement === overlay) {
2035
+ await doc.exitFullscreen();
2036
+ setMode("popout");
2037
+ } else {
2038
+ await overlay.requestFullscreen();
2039
+ setMode("fullscreen");
2040
+ }
2041
+ }
2042
+ function handleOverlayClick(event) {
2043
+ if (event.target === overlay) {
2044
+ closePopout();
2045
+ }
2046
+ }
2047
+ function handleFullscreenChange() {
2048
+ if (!popoutOpen && doc.fullscreenElement === overlay) {
2049
+ void doc.exitFullscreen();
2050
+ return;
2051
+ }
2052
+ if (!doc.fullscreenElement && popoutOpen) {
2053
+ setMode("popout");
2054
+ } else if (doc.fullscreenElement === overlay) {
2055
+ setMode("fullscreen");
2056
+ }
2057
+ }
2058
+ overlay.addEventListener("click", handleOverlayClick);
2059
+ doc.addEventListener("fullscreenchange", handleFullscreenChange);
2060
+ syncOverlayState();
2061
+ options.onModeChange?.(mode);
2062
+ return {
2063
+ openPopout,
2064
+ closePopout,
2065
+ toggleFullscreen,
2066
+ getMode: () => mode,
2067
+ destroy: () => {
2068
+ doc.removeEventListener("fullscreenchange", handleFullscreenChange);
2069
+ overlay.removeEventListener("click", handleOverlayClick);
2070
+ closePopout();
2071
+ overlay.remove();
2072
+ }
2073
+ };
2074
+ }
2075
+
2076
+ // src/viewerOverlay.ts
2077
+ var STYLE_ID = "ctw-viewer-overlay-styles";
2078
+ var PROGRESS_VISIBILITY_DELAY_MS = 500;
2079
+ var PROGRESS_FADE_IN_DURATION_MS = 200;
2080
+ var PROGRESS_FADE_OUT_DURATION_MS = 500;
2081
+ var PROGRESS_CLEAR_DELAY_MS = PROGRESS_FADE_OUT_DURATION_MS;
2082
+ var PROGRESS_FADE_DURATION_VAR = "--ctw-viewer-progress-fade-duration";
2083
+ var OVERLAY_CSS = `
2084
+ .ctw-viewer-host {
2085
+ position: relative;
2086
+ }
2087
+
2088
+ .ctw-viewer-overlay {
2089
+ position: absolute;
2090
+ inset: 0;
2091
+ display: flex;
2092
+ flex-direction: column;
2093
+ justify-content: space-between;
2094
+ padding: 0.75rem;
2095
+ pointer-events: none;
2096
+ font-family: 'Inter', 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', system-ui, sans-serif;
2097
+ color: #f6e7c3;
2098
+ z-index: 10;
2099
+ }
2100
+
2101
+ .ctw-viewer-overlay__row {
2102
+ display: grid;
2103
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
2104
+ column-gap: 0.75rem;
2105
+ row-gap: 0.5rem;
2106
+ align-items: center;
2107
+ width: 100%;
2108
+ }
2109
+
2110
+ .ctw-viewer-overlay__row--bottom {
2111
+ margin-top: 0.5rem;
2112
+ }
2113
+
2114
+ .ctw-viewer-overlay__slot {
2115
+ display: flex;
2116
+ gap: 0.35rem;
2117
+ align-items: center;
2118
+ flex-wrap: wrap;
2119
+ }
2120
+
2121
+ .ctw-viewer-overlay__slot--top-left,
2122
+ .ctw-viewer-overlay__slot--top-right,
2123
+ .ctw-viewer-overlay__slot--top-center,
2124
+ .ctw-viewer-overlay__slot--bottom-left,
2125
+ .ctw-viewer-overlay__slot--bottom-right,
2126
+ .ctw-viewer-overlay__slot--bottom-center {
2127
+ justify-content: flex-start;
2128
+ justify-self: flex-start;
2129
+ }
2130
+
2131
+ .ctw-viewer-overlay__slot--top-right,
2132
+ .ctw-viewer-overlay__slot--bottom-right {
2133
+ justify-content: flex-end;
2134
+ justify-self: flex-end;
2135
+ }
2136
+
2137
+ .ctw-viewer-overlay__slot--top-center,
2138
+ .ctw-viewer-overlay__slot--bottom-center {
2139
+ justify-content: center;
2140
+ justify-self: center;
2141
+ text-align: center;
2142
+ }
2143
+
2144
+ .ctw-viewer-overlay__center {
2145
+ position: absolute;
2146
+ top: 50%;
2147
+ left: 50%;
2148
+ transform: translate(-50%, -50%);
2149
+ display: flex;
2150
+ flex-direction: column;
2151
+ gap: 0.5rem;
2152
+ align-items: center;
2153
+ pointer-events: none;
2154
+ }
2155
+
2156
+ .ctw-viewer-overlay__center > * {
2157
+ pointer-events: auto;
2158
+ }
2159
+
2160
+ .ctw-viewer-overlay__progress {
2161
+ position: absolute;
2162
+ left: 0.75rem;
2163
+ right: 0.75rem;
2164
+ bottom: 0.75rem;
2165
+ background: rgba(5, 7, 13, 0.85);
2166
+ border: 1px solid rgba(223, 195, 135, 0.5);
2167
+ border-radius: 12px;
2168
+ padding: 0.65rem 0.85rem 0.8rem;
2169
+ pointer-events: none;
2170
+ opacity: 0;
2171
+ transition: opacity var(${PROGRESS_FADE_DURATION_VAR}, 0.1s) ease;
2172
+ }
2173
+
2174
+ .ctw-viewer-overlay__progress--visible {
2175
+ opacity: 1;
2176
+ }
2177
+
2178
+ .ctw-viewer-overlay__progress-label {
2179
+ font-size: 0.75rem;
2180
+ letter-spacing: 0.08em;
2181
+ text-transform: uppercase;
2182
+ color: rgba(246, 231, 195, 0.9);
2183
+ margin-bottom: 0.2rem;
2184
+ }
2185
+
2186
+ .ctw-viewer-overlay__progress-value {
2187
+ font-size: 0.78rem;
2188
+ color: rgba(246, 231, 195, 0.75);
2189
+ margin-bottom: 0.45rem;
2190
+ }
2191
+
2192
+ .ctw-viewer-overlay__progress-bar {
2193
+ position: relative;
2194
+ height: 0.4rem;
2195
+ border-radius: 999px;
2196
+ background: rgba(246, 231, 195, 0.22);
2197
+ overflow: hidden;
2198
+ }
2199
+
2200
+ .ctw-viewer-overlay__progress-fill {
2201
+ position: absolute;
2202
+ inset: 0;
2203
+ width: 0%;
2204
+ background: linear-gradient(90deg, #f6e7c3 0%, #d7c289 100%);
2205
+ border-radius: inherit;
2206
+ transition: width 0.2s ease;
2207
+ }
2208
+
2209
+ .ctw-viewer-overlay__progress-bar--indeterminate .ctw-viewer-overlay__progress-fill {
2210
+ width: 40%;
2211
+ animation: ctw-viewer-progress-indeterminate 0.9s linear infinite;
2212
+ }
2213
+
2214
+ @keyframes ctw-viewer-progress-indeterminate {
2215
+ 0% {
2216
+ transform: translateX(-100%);
2217
+ }
2218
+ 100% {
2219
+ transform: translateX(250%);
2220
+ }
2221
+ }
2222
+
2223
+ .ctw-viewer-overlay__status {
2224
+ font-size: 0.85rem;
2225
+ background: rgba(5, 7, 13, 0.6);
2226
+ border: 1px solid rgba(223, 195, 135, 0.5);
2227
+ border-radius: 999px;
2228
+ padding: 0.35rem 0.9rem;
2229
+ backdrop-filter: blur(6px);
2230
+ line-height: 1.2rem;
2231
+ white-space: nowrap;
2232
+ overflow: hidden;
2233
+ max-width: max(150px, 50%, 30vw);
2234
+ text-overflow: ellipsis;
2235
+ transition: opacity 0.45s ease, transform 0.45s ease;
2236
+ }
2237
+
2238
+ .ctw-viewer-overlay__status--fade {
2239
+ opacity: 0;
2240
+ transform: translateY(-6px);
2241
+ }
2242
+
2243
+ .ctw-viewer-overlay__buttons {
2244
+ display: flex;
2245
+ gap: 0.35rem;
2246
+ }
2247
+
2248
+ .ctw-viewer-overlay__label {
2249
+ font-size: 0.85rem;
2250
+ letter-spacing: 0.03em;
2251
+ color: rgba(246, 231, 195, 0.85);
2252
+ }
2253
+
2254
+ .ctw-chip-button {
2255
+ background: rgba(5, 7, 13, 0.65);
2256
+ border: 1px solid rgba(223, 195, 135, 0.5);
2257
+ color: #f6e7c3;
2258
+ border-radius: 999px;
2259
+ padding: 0.4rem 0.95rem;
2260
+ font-size: 0.82rem;
2261
+ letter-spacing: 0.02em;
2262
+ pointer-events: auto;
2263
+ cursor: pointer;
2264
+ transition: border-color 0.2s ease;
2265
+ line-height: 1.2rem;
2266
+ white-space: nowrap;
2267
+ overflow: hidden;
2268
+ text-overflow: ellipsis;
2269
+ max-width: max(100px, 20vw);
2270
+ }
2271
+
2272
+ .ctw-chip-button:disabled {
2273
+ opacity: 0.45;
2274
+ cursor: not-allowed;
2275
+ }
2276
+
2277
+ .ctw-chip-button:not(:disabled):hover {
2278
+ border-color: rgba(223, 195, 135, 0.85);
2279
+ }
2280
+
2281
+ .ctw-drop-overlay {
2282
+ position: absolute;
2283
+ inset: 0;
2284
+ border: 2px dashed rgba(223, 195, 135, 0.75);
2285
+ border-radius: 16px;
2286
+ background: rgba(5, 7, 13, 0.82);
2287
+ display: none;
2288
+ align-items: center;
2289
+ justify-content: center;
2290
+ font-weight: 600;
2291
+ letter-spacing: 0.08em;
2292
+ text-transform: uppercase;
2293
+ z-index: 9;
2294
+ }
2295
+
2296
+ .ctw-viewer-host--dragging .ctw-drop-overlay {
2297
+ display: flex;
2298
+ }
2299
+ `;
2300
+ function resolveViewerOverlayOptions(options = {}) {
2301
+ return {
2302
+ status: {
2303
+ initialText: options.status?.initialText ?? "Loading\u2026"
2304
+ },
2305
+ selectFile: {
2306
+ enabled: options.selectFile?.enabled ?? true,
2307
+ label: options.selectFile?.label ?? "Load Map",
2308
+ callback: options.selectFile?.callback
2309
+ },
2310
+ popout: {
2311
+ enabled: options.popout?.enabled ?? true,
2312
+ labelOpen: options.popout?.labelOpen ?? "Pop Out",
2313
+ labelClose: options.popout?.labelClose ?? "Close",
2314
+ onRequestOpen: options.popout?.onRequestOpen,
2315
+ onRequestClose: options.popout?.onRequestClose
2316
+ },
2317
+ fullscreen: {
2318
+ enabled: options.fullscreen?.enabled ?? true,
2319
+ labelEnter: options.fullscreen?.labelEnter ?? "Full Screen",
2320
+ labelExit: options.fullscreen?.labelExit ?? "Exit Full Screen",
2321
+ onToggle: options.fullscreen?.onToggle,
2322
+ displayInEmbed: options.fullscreen?.displayInEmbed ?? false
2323
+ },
2324
+ customButtons: options.customButtons ?? []
2325
+ };
2326
+ }
2327
+ function formatBytes(bytes) {
2328
+ if (bytes <= 0 || !Number.isFinite(bytes)) return "0 B";
2329
+ const units = ["B", "KB", "MB", "GB"];
2330
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
2331
+ const value = bytes / Math.pow(1024, exponent);
2332
+ const rounded = value >= 10 ? value.toFixed(0) : value.toFixed(1);
2333
+ return `${rounded} ${units[exponent]}`;
2334
+ }
2335
+ function ensureStyles(doc) {
2336
+ if (doc.getElementById(STYLE_ID)) return;
2337
+ const style = doc.createElement("style");
2338
+ style.id = STYLE_ID;
2339
+ style.textContent = OVERLAY_CSS;
2340
+ doc.head.appendChild(style);
2341
+ }
2342
+ function createViewerOverlay(target, optionsInput = {}) {
2343
+ if (typeof window === "undefined") {
2344
+ return {
2345
+ setStatus: () => {
2346
+ },
2347
+ setStatusFade: () => {
2348
+ },
2349
+ setViewMode: () => {
2350
+ },
2351
+ setLoadingProgress: () => {
2352
+ },
2353
+ hideDropOverlay: () => {
2354
+ },
2355
+ openFileDialog: () => {
2356
+ },
2357
+ destroy: () => {
2358
+ }
2359
+ };
2360
+ }
2361
+ const options = resolveViewerOverlayOptions(optionsInput);
2362
+ const doc = target.ownerDocument;
2363
+ ensureStyles(doc);
2364
+ target.classList.add("ctw-viewer-host");
2365
+ const overlay = doc.createElement("div");
2366
+ overlay.className = "ctw-viewer-overlay";
2367
+ target.appendChild(overlay);
2368
+ const createSlot = (position) => {
2369
+ const slot = doc.createElement("div");
2370
+ slot.className = `ctw-viewer-overlay__slot ctw-viewer-overlay__slot--${position}`;
2371
+ return slot;
2372
+ };
2373
+ const topRow = doc.createElement("div");
2374
+ topRow.className = "ctw-viewer-overlay__row ctw-viewer-overlay__row--top";
2375
+ const topLeftSlot = createSlot("top-left");
2376
+ const topCenterSlot = createSlot("top-center");
2377
+ const topRightSlot = createSlot("top-right");
2378
+ topRow.append(topLeftSlot, topCenterSlot, topRightSlot);
2379
+ overlay.append(topRow);
2380
+ const bottomRow = doc.createElement("div");
2381
+ bottomRow.className = "ctw-viewer-overlay__row ctw-viewer-overlay__row--bottom";
2382
+ const bottomLeftSlot = createSlot("bottom-left");
2383
+ const bottomCenterSlot = createSlot("bottom-center");
2384
+ const bottomRightSlot = createSlot("bottom-right");
2385
+ bottomRow.append(bottomLeftSlot, bottomCenterSlot, bottomRightSlot);
2386
+ const bottomLocations = /* @__PURE__ */ new Set([
2387
+ "bottom-left",
2388
+ "bottom-center",
2389
+ "bottom-right"
2390
+ ]);
2391
+ let bottomRowMounted = false;
2392
+ const centerSlot = doc.createElement("div");
2393
+ centerSlot.className = "ctw-viewer-overlay__center";
2394
+ let centerSlotMounted = false;
2395
+ const progressPanel = doc.createElement("div");
2396
+ progressPanel.className = "ctw-viewer-overlay__progress";
2397
+ const progressLabel = doc.createElement("div");
2398
+ progressLabel.className = "ctw-viewer-overlay__progress-label";
2399
+ const progressValue = doc.createElement("div");
2400
+ progressValue.className = "ctw-viewer-overlay__progress-value";
2401
+ const progressBar = doc.createElement("div");
2402
+ progressBar.className = "ctw-viewer-overlay__progress-bar";
2403
+ const progressFill = doc.createElement("div");
2404
+ progressFill.className = "ctw-viewer-overlay__progress-fill";
2405
+ progressBar.append(progressFill);
2406
+ progressPanel.append(progressLabel, progressValue, progressBar);
2407
+ overlay.append(progressPanel);
2408
+ const slotMap = {
2409
+ "top-left": topLeftSlot,
2410
+ "top-center": topCenterSlot,
2411
+ "top-right": topRightSlot,
2412
+ "bottom-left": bottomLeftSlot,
2413
+ "bottom-center": bottomCenterSlot,
2414
+ "bottom-right": bottomRightSlot
2415
+ };
2416
+ function appendToSlot(location, element) {
2417
+ if (location === "center") {
2418
+ if (!centerSlotMounted) {
2419
+ overlay.append(centerSlot);
2420
+ centerSlotMounted = true;
2421
+ }
2422
+ centerSlot.appendChild(element);
2423
+ return;
2424
+ }
2425
+ if (bottomLocations.has(location) && !bottomRowMounted) {
2426
+ overlay.insertBefore(bottomRow, progressPanel);
2427
+ bottomRowMounted = true;
2428
+ }
2429
+ slotMap[location].appendChild(element);
2430
+ }
2431
+ const statusLabel = doc.createElement("div");
2432
+ statusLabel.className = "ctw-viewer-overlay__status";
2433
+ statusLabel.textContent = options.status.initialText;
2434
+ appendToSlot("top-left", statusLabel);
2435
+ function setStatusFade(fade) {
2436
+ statusLabel.classList.toggle("ctw-viewer-overlay__status--fade", fade);
2437
+ }
2438
+ const buttonGroup = doc.createElement("div");
2439
+ buttonGroup.className = "ctw-viewer-overlay__buttons";
2440
+ appendToSlot("top-right", buttonGroup);
2441
+ let fileInput = null;
2442
+ let openFileDialogImpl = () => {
2443
+ };
2444
+ let dropOverlay = null;
2445
+ let dragEnter = null;
2446
+ let dragOver = null;
2447
+ let dragLeave = null;
2448
+ let drop = null;
2449
+ let loadBtn = null;
2450
+ if (options.selectFile.enabled) {
2451
+ loadBtn = doc.createElement("button");
2452
+ loadBtn.type = "button";
2453
+ loadBtn.className = "ctw-chip-button";
2454
+ loadBtn.textContent = options.selectFile.label;
2455
+ const canHandleFiles = Boolean(options.selectFile.callback);
2456
+ loadBtn.disabled = !canHandleFiles;
2457
+ buttonGroup.appendChild(loadBtn);
2458
+ if (canHandleFiles) {
2459
+ fileInput = doc.createElement("input");
2460
+ fileInput.type = "file";
2461
+ fileInput.accept = ".wyn";
2462
+ fileInput.style.display = "none";
2463
+ target.appendChild(fileInput);
2464
+ let handleFiles = (files) => {
2465
+ const file = files?.item(0);
2466
+ if (!file) return;
2467
+ options.selectFile.callback?.(file);
2468
+ };
2469
+ const openDialog = () => fileInput?.click();
2470
+ openFileDialogImpl = openDialog;
2471
+ loadBtn.addEventListener("click", openDialog);
2472
+ fileInput.addEventListener("change", () => {
2473
+ void handleFiles(fileInput?.files ?? null);
2474
+ if (fileInput) {
2475
+ fileInput.value = "";
2476
+ }
2477
+ });
2478
+ dropOverlay = doc.createElement("div");
2479
+ dropOverlay.className = "ctw-drop-overlay";
2480
+ dropOverlay.textContent = "Drop .wyn to load";
2481
+ dropOverlay.style.display = "none";
2482
+ target.appendChild(dropOverlay);
2483
+ const prevent = (event) => {
2484
+ event.preventDefault();
2485
+ event.stopPropagation();
2486
+ };
2487
+ const showOverlay = () => {
2488
+ if (dropOverlay) dropOverlay.style.display = "flex";
2489
+ };
2490
+ const hideOverlay = () => {
2491
+ if (dropOverlay) dropOverlay.style.display = "none";
2492
+ };
2493
+ const highlight = () => {
2494
+ target.classList.add("ctw-viewer-host--dragging");
2495
+ showOverlay();
2496
+ };
2497
+ const unhighlight = () => {
2498
+ target.classList.remove("ctw-viewer-host--dragging");
2499
+ hideOverlay();
2500
+ };
2501
+ handleFiles = async (files) => {
2502
+ const file = files?.item(0);
2503
+ if (!file) {
2504
+ unhighlight();
2505
+ return;
2506
+ }
2507
+ try {
2508
+ await Promise.resolve(options.selectFile.callback?.(file));
2509
+ } finally {
2510
+ unhighlight();
2511
+ }
2512
+ };
2513
+ dragEnter = (event) => {
2514
+ prevent(event);
2515
+ highlight();
2516
+ };
2517
+ dragOver = (event) => {
2518
+ prevent(event);
2519
+ highlight();
2520
+ };
2521
+ dragLeave = (event) => {
2522
+ prevent(event);
2523
+ const related = event.relatedTarget;
2524
+ if (!related || !target.contains(related)) {
2525
+ unhighlight();
2526
+ }
2527
+ };
2528
+ drop = (event) => {
2529
+ prevent(event);
2530
+ unhighlight();
2531
+ void handleFiles(event.dataTransfer?.files ?? null);
2532
+ };
2533
+ target.addEventListener("dragenter", dragEnter);
2534
+ target.addEventListener("dragover", dragOver);
2535
+ target.addEventListener("dragleave", dragLeave);
2536
+ target.addEventListener("drop", drop);
2537
+ }
2538
+ }
2539
+ let modeBtn = null;
2540
+ if (options.popout.enabled) {
2541
+ modeBtn = doc.createElement("button");
2542
+ modeBtn.type = "button";
2543
+ modeBtn.className = "ctw-chip-button";
2544
+ modeBtn.textContent = options.popout.labelOpen;
2545
+ buttonGroup.appendChild(modeBtn);
2546
+ modeBtn.addEventListener("click", () => {
2547
+ if (currentMode === "embed") {
2548
+ options.popout.onRequestOpen?.();
2549
+ } else {
2550
+ options.popout.onRequestClose?.();
2551
+ }
2552
+ });
2553
+ }
2554
+ let fullscreenBtn = null;
2555
+ if (options.fullscreen.enabled) {
2556
+ fullscreenBtn = doc.createElement("button");
2557
+ fullscreenBtn.type = "button";
2558
+ fullscreenBtn.className = "ctw-chip-button";
2559
+ fullscreenBtn.textContent = options.fullscreen.labelEnter;
2560
+ buttonGroup.appendChild(fullscreenBtn);
2561
+ fullscreenBtn.addEventListener("click", () => {
2562
+ options.fullscreen.onToggle?.();
2563
+ });
2564
+ }
2565
+ if (!buttonGroup.childElementCount) {
2566
+ buttonGroup.remove();
2567
+ }
2568
+ options.customButtons.forEach((custom) => {
2569
+ if (custom.callback) {
2570
+ const button = doc.createElement("button");
2571
+ button.type = "button";
2572
+ button.className = "ctw-chip-button";
2573
+ button.textContent = custom.label;
2574
+ if (custom.description) {
2575
+ button.title = custom.description;
2576
+ }
2577
+ button.addEventListener("click", () => custom.callback?.());
2578
+ appendToSlot(custom.location, button);
2579
+ } else {
2580
+ const label = doc.createElement("span");
2581
+ label.className = "ctw-viewer-overlay__label";
2582
+ label.textContent = custom.label;
2583
+ appendToSlot(custom.location, label);
2584
+ }
2585
+ });
2586
+ let currentMode = "embed";
2587
+ let currentLoading = null;
2588
+ let progressVisible = false;
2589
+ let progressTimer = null;
2590
+ let progressClearTimer = null;
2591
+ function resetProgressContent() {
2592
+ progressLabel.textContent = "";
2593
+ progressValue.textContent = "";
2594
+ progressFill.style.width = "0%";
2595
+ progressBar.classList.remove("ctw-viewer-overlay__progress-bar--indeterminate");
2596
+ }
2597
+ function cancelProgressContentReset() {
2598
+ if (progressClearTimer) {
2599
+ window.clearTimeout(progressClearTimer);
2600
+ progressClearTimer = null;
2601
+ }
2602
+ }
2603
+ function scheduleProgressContentReset() {
2604
+ cancelProgressContentReset();
2605
+ progressClearTimer = window.setTimeout(() => {
2606
+ resetProgressContent();
2607
+ progressClearTimer = null;
2608
+ }, PROGRESS_CLEAR_DELAY_MS);
2609
+ }
2610
+ function setProgressFadeDuration(durationMs) {
2611
+ progressPanel.style.setProperty(PROGRESS_FADE_DURATION_VAR, `${durationMs}ms`);
2612
+ }
2613
+ function showProgressPanel() {
2614
+ if (progressVisible) return;
2615
+ setProgressFadeDuration(PROGRESS_FADE_IN_DURATION_MS);
2616
+ progressPanel.classList.add("ctw-viewer-overlay__progress--visible");
2617
+ progressVisible = true;
2618
+ }
2619
+ function hideProgressPanel() {
2620
+ if (progressTimer) {
2621
+ window.clearTimeout(progressTimer);
2622
+ progressTimer = null;
2623
+ }
2624
+ setProgressFadeDuration(PROGRESS_FADE_OUT_DURATION_MS);
2625
+ progressPanel.classList.remove("ctw-viewer-overlay__progress--visible");
2626
+ progressVisible = false;
2627
+ }
2628
+ function updateProgressPanel(state) {
2629
+ currentLoading = state;
2630
+ if (!state) {
2631
+ hideProgressPanel();
2632
+ scheduleProgressContentReset();
2633
+ return;
2634
+ }
2635
+ cancelProgressContentReset();
2636
+ progressLabel.textContent = state.label;
2637
+ if (typeof state.totalBytes === "number" && state.totalBytes > 0) {
2638
+ const percent = Math.min(state.loadedBytes / state.totalBytes, 1);
2639
+ const percentText = `${Math.round(percent * 100)}%`;
2640
+ progressValue.textContent = `${percentText} \u2014 ${formatBytes(state.loadedBytes)} / ${formatBytes(state.totalBytes)}`;
2641
+ progressFill.style.width = `${percent * 100}%`;
2642
+ progressBar.classList.remove("ctw-viewer-overlay__progress-bar--indeterminate");
2643
+ } else {
2644
+ progressValue.textContent = `${formatBytes(state.loadedBytes)}`;
2645
+ progressFill.style.width = "40%";
2646
+ progressBar.classList.add("ctw-viewer-overlay__progress-bar--indeterminate");
2647
+ }
2648
+ if (!progressVisible && !progressTimer) {
2649
+ progressTimer = window.setTimeout(() => {
2650
+ progressTimer = null;
2651
+ if (!currentLoading) return;
2652
+ showProgressPanel();
2653
+ }, PROGRESS_VISIBILITY_DELAY_MS);
2654
+ }
2655
+ }
2656
+ function applyMode(mode) {
2657
+ currentMode = mode;
2658
+ if (modeBtn) {
2659
+ modeBtn.disabled = false;
2660
+ modeBtn.textContent = mode === "embed" ? options.popout.labelOpen : options.popout.labelClose;
2661
+ }
2662
+ if (fullscreenBtn) {
2663
+ const shouldShowFullscreen = options.fullscreen.displayInEmbed || mode !== "embed";
2664
+ fullscreenBtn.hidden = !shouldShowFullscreen;
2665
+ fullscreenBtn.textContent = mode === "fullscreen" ? options.fullscreen.labelExit : options.fullscreen.labelEnter;
2666
+ }
2667
+ }
2668
+ applyMode("embed");
2669
+ return {
2670
+ setStatus(message) {
2671
+ statusLabel.textContent = message;
2672
+ setStatusFade(false);
2673
+ },
2674
+ setStatusFade,
2675
+ setViewMode(mode) {
2676
+ applyMode(mode);
2677
+ },
2678
+ setLoadingProgress(state) {
2679
+ updateProgressPanel(state);
2680
+ },
2681
+ hideDropOverlay() {
2682
+ if (dropOverlay) {
2683
+ dropOverlay.style.display = "none";
2684
+ target.classList.remove("ctw-viewer-host--dragging");
2685
+ }
2686
+ },
2687
+ openFileDialog() {
2688
+ openFileDialogImpl();
2689
+ },
2690
+ destroy() {
2691
+ hideProgressPanel();
2692
+ cancelProgressContentReset();
2693
+ resetProgressContent();
2694
+ if (dragEnter) {
2695
+ target.removeEventListener("dragenter", dragEnter);
2696
+ }
2697
+ if (dragOver) {
2698
+ target.removeEventListener("dragover", dragOver);
2699
+ }
2700
+ if (dragLeave) {
2701
+ target.removeEventListener("dragleave", dragLeave);
2702
+ }
2703
+ if (drop) {
2704
+ target.removeEventListener("drop", drop);
2705
+ }
2706
+ overlay.remove();
2707
+ fileInput?.remove();
2708
+ dropOverlay?.remove();
2709
+ target.classList.remove("ctw-viewer-host", "ctw-viewer-host--dragging");
2710
+ }
2711
+ };
2712
+ }
2713
+
2714
+ // src/editor/projectStore.ts
2715
+ var DEFAULT_METADATA = {
2716
+ label: "Untitled terrain",
2717
+ author: "",
2718
+ source: "scratch"
2719
+ };
2720
+ function cloneEntry(entry) {
2721
+ return {
2722
+ ...entry,
2723
+ data: entry.data.slice(0)
2724
+ };
2725
+ }
2726
+ function createInternalState(initial) {
2727
+ const files = /* @__PURE__ */ new Map();
2728
+ initial?.files?.forEach((entry) => files.set(entry.path, cloneEntry(entry)));
2729
+ return {
2730
+ legend: initial?.legend,
2731
+ locations: initial?.locations ? [...initial.locations] : void 0,
2732
+ theme: initial?.theme,
2733
+ metadata: { ...DEFAULT_METADATA, ...initial?.metadata },
2734
+ files,
2735
+ dirty: Boolean(initial?.dirty)
2736
+ };
2737
+ }
2738
+ function createProjectStore(initial) {
2739
+ const state = createInternalState(initial);
2740
+ const listeners = /* @__PURE__ */ new Set();
2741
+ function buildSnapshot() {
2742
+ return {
2743
+ legend: state.legend,
2744
+ locations: state.locations ? [...state.locations] : void 0,
2745
+ theme: state.theme,
2746
+ metadata: { ...state.metadata },
2747
+ files: Array.from(state.files.values()).map((entry) => ({ ...entry })),
2748
+ dirty: state.dirty
2749
+ };
2750
+ }
2751
+ function emit() {
2752
+ const snapshot = buildSnapshot();
2753
+ listeners.forEach((listener) => listener(snapshot));
2754
+ }
2755
+ function resetInternal() {
2756
+ state.legend = void 0;
2757
+ state.locations = void 0;
2758
+ state.theme = void 0;
2759
+ state.metadata = { ...DEFAULT_METADATA };
2760
+ state.files.clear();
2761
+ state.dirty = false;
2762
+ }
2763
+ function loadFromArchive(payload) {
2764
+ state.legend = payload.legend;
2765
+ state.locations = payload.locations ? [...payload.locations] : void 0;
2766
+ state.theme = payload.theme;
2767
+ state.metadata = {
2768
+ ...DEFAULT_METADATA,
2769
+ ...payload.metadata,
2770
+ source: "archive"
2771
+ };
2772
+ state.files.clear();
2773
+ payload.files?.forEach((entry) => {
2774
+ state.files.set(entry.path, cloneEntry(entry));
2775
+ });
2776
+ state.dirty = false;
2777
+ emit();
2778
+ }
2779
+ function upsertFile(entry) {
2780
+ state.files.set(entry.path, cloneEntry(entry));
2781
+ state.dirty = true;
2782
+ emit();
2783
+ }
2784
+ function setLegend(legend) {
2785
+ state.legend = legend;
2786
+ state.dirty = true;
2787
+ emit();
2788
+ }
2789
+ function setLocations(locations) {
2790
+ state.locations = locations ? [...locations] : void 0;
2791
+ state.dirty = true;
2792
+ emit();
2793
+ }
2794
+ function setTheme(theme) {
2795
+ state.theme = theme;
2796
+ state.dirty = true;
2797
+ emit();
2798
+ }
2799
+ function updateMetadata(metadata) {
2800
+ state.metadata = { ...state.metadata, ...metadata };
2801
+ state.dirty = true;
2802
+ emit();
2803
+ }
2804
+ function removeFile(path) {
2805
+ if (!state.files.has(path)) return;
2806
+ state.files.delete(path);
2807
+ state.dirty = true;
2808
+ emit();
2809
+ }
2810
+ function reset() {
2811
+ resetInternal();
2812
+ emit();
2813
+ }
2814
+ function markPersisted() {
2815
+ if (!state.dirty) return;
2816
+ state.dirty = false;
2817
+ emit();
2818
+ }
2819
+ function getSnapshot() {
2820
+ return buildSnapshot();
2821
+ }
2822
+ function subscribe(listener) {
2823
+ listeners.add(listener);
2824
+ listener(buildSnapshot());
2825
+ return () => {
2826
+ listeners.delete(listener);
2827
+ };
2828
+ }
2829
+ return {
2830
+ getSnapshot,
2831
+ subscribe,
2832
+ loadFromArchive,
2833
+ setLegend,
2834
+ setLocations,
2835
+ setTheme,
2836
+ updateMetadata,
2837
+ upsertFile,
2838
+ removeFile,
2839
+ reset,
2840
+ markPersisted
2841
+ };
2842
+ }
2843
+
2844
+ // src/editor/buildWynArchive.ts
2845
+ import JSZip2 from "jszip";
2846
+ function ensureLegend(project) {
2847
+ if (!project.legend) {
2848
+ throw new Error("Cannot build WYN archive without a legend.json payload.");
2849
+ }
2850
+ }
2851
+ function stringify(payload, options) {
2852
+ const indent = options.pretty ? 2 : void 0;
2853
+ return JSON.stringify(payload, null, indent);
2854
+ }
2855
+ async function buildWynArchive(project, options = {}) {
2856
+ ensureLegend(project);
2857
+ const zip = new JSZip2();
2858
+ zip.file("legend.json", stringify(project.legend, options));
2859
+ if (project.locations?.length) {
2860
+ zip.file("locations.json", stringify(project.locations, options));
2861
+ }
2862
+ if (project.theme) {
2863
+ zip.file("theme.json", stringify(project.theme, options));
2864
+ }
2865
+ if (project.metadata) {
2866
+ zip.file("metadata.json", stringify(project.metadata, options));
2867
+ }
2868
+ for (const file of project.files) {
2869
+ zip.file(file.path, file.data, {
2870
+ binary: true,
2871
+ comment: file.sourceFileName ?? ""
2872
+ });
2873
+ }
2874
+ return zip.generateAsync({ type: "blob" });
2875
+ }
2876
+
2877
+ // src/editor/layerBrowser.ts
2878
+ function defaultLayerToggles() {
2879
+ return { biomes: {}, overlays: {} };
2880
+ }
2881
+ function makeLayerId(kind, key) {
2882
+ return `${kind}:${key}`;
2883
+ }
2884
+ function buildEntries(legend, visibility) {
2885
+ if (!legend) return [];
2886
+ const entries = [];
2887
+ for (const [key, layer] of Object.entries(legend.biomes ?? {})) {
2888
+ const id = makeLayerId("biome", key);
2889
+ entries.push({
2890
+ id,
2891
+ kind: "biome",
2892
+ label: layer.label ?? key,
2893
+ mask: layer.mask,
2894
+ color: layer.rgb,
2895
+ visible: visibility.get(id) ?? true
2896
+ });
2897
+ }
2898
+ for (const [key, layer] of Object.entries(legend.overlays ?? {})) {
2899
+ const id = makeLayerId("overlay", key);
2900
+ entries.push({
2901
+ id,
2902
+ kind: "overlay",
2903
+ label: layer.label ?? key,
2904
+ mask: layer.mask,
2905
+ color: layer.rgb,
2906
+ visible: visibility.get(id) ?? true
2907
+ });
2908
+ }
2909
+ return entries;
2910
+ }
2911
+ function updateVisibilityFromLegend(legend, visibility) {
2912
+ if (!legend) return;
2913
+ for (const key of Object.keys(legend.biomes ?? {})) {
2914
+ const id = makeLayerId("biome", key);
2915
+ if (!visibility.has(id)) {
2916
+ visibility.set(id, true);
2917
+ }
2918
+ }
2919
+ for (const key of Object.keys(legend.overlays ?? {})) {
2920
+ const id = makeLayerId("overlay", key);
2921
+ if (!visibility.has(id)) {
2922
+ visibility.set(id, true);
2923
+ }
2924
+ }
2925
+ }
2926
+ function createLayerBrowserStore(legend, initial) {
2927
+ const visibility = /* @__PURE__ */ new Map();
2928
+ if (initial?.biomes) {
2929
+ for (const [key, value] of Object.entries(initial.biomes)) {
2930
+ visibility.set(makeLayerId("biome", key), value);
2931
+ }
2932
+ }
2933
+ if (initial?.overlays) {
2934
+ for (const [key, value] of Object.entries(initial.overlays)) {
2935
+ visibility.set(makeLayerId("overlay", key), value);
2936
+ }
2937
+ }
2938
+ const state = {
2939
+ legend,
2940
+ entries: buildEntries(legend, visibility)
2941
+ };
2942
+ const listeners = /* @__PURE__ */ new Set();
2943
+ function emit() {
2944
+ const snapshot = {
2945
+ legend: state.legend,
2946
+ entries: state.entries.map((entry) => ({ ...entry }))
2947
+ };
2948
+ listeners.forEach((listener) => listener(snapshot));
2949
+ }
2950
+ function setLegend(next) {
2951
+ state.legend = next;
2952
+ if (!state.legend) {
2953
+ visibility.clear();
2954
+ state.entries = [];
2955
+ emit();
2956
+ return;
2957
+ }
2958
+ updateVisibilityFromLegend(state.legend, visibility);
2959
+ const nextEntries = buildEntries(state.legend, visibility);
2960
+ const validIds = new Set(nextEntries.map((entry) => entry.id));
2961
+ for (const key of Array.from(visibility.keys())) {
2962
+ if (!validIds.has(key)) {
2963
+ visibility.delete(key);
2964
+ }
2965
+ }
2966
+ state.entries = nextEntries;
2967
+ emit();
2968
+ }
2969
+ function setVisibility(id, visible) {
2970
+ if (!state.entries.some((entry) => entry.id === id)) return;
2971
+ visibility.set(id, visible);
2972
+ state.entries = state.entries.map(
2973
+ (entry) => entry.id === id ? { ...entry, visible } : entry
2974
+ );
2975
+ emit();
2976
+ }
2977
+ function toggleVisibility(id) {
2978
+ const next = !(visibility.get(id) ?? true);
2979
+ setVisibility(id, next);
2980
+ }
2981
+ function setAll(kind, visible) {
2982
+ let mutated = false;
2983
+ state.entries = state.entries.map((entry) => {
2984
+ if (entry.kind !== kind) return entry;
2985
+ mutated = true;
2986
+ visibility.set(entry.id, visible);
2987
+ return { ...entry, visible };
2988
+ });
2989
+ if (mutated) {
2990
+ emit();
2991
+ }
2992
+ }
2993
+ function getLayerToggles() {
2994
+ const toggles = defaultLayerToggles();
2995
+ if (!state.legend) return toggles;
2996
+ for (const [key] of Object.entries(state.legend.biomes ?? {})) {
2997
+ const id = makeLayerId("biome", key);
2998
+ toggles.biomes[key] = visibility.get(id) ?? true;
2999
+ }
3000
+ for (const [key] of Object.entries(state.legend.overlays ?? {})) {
3001
+ const id = makeLayerId("overlay", key);
3002
+ toggles.overlays[key] = visibility.get(id) ?? true;
3003
+ }
3004
+ return toggles;
3005
+ }
3006
+ function getState() {
3007
+ return {
3008
+ legend: state.legend,
3009
+ entries: state.entries.map((entry) => ({ ...entry }))
3010
+ };
3011
+ }
3012
+ function subscribe(listener) {
3013
+ listeners.add(listener);
3014
+ listener(getState());
3015
+ return () => {
3016
+ listeners.delete(listener);
3017
+ };
3018
+ }
3019
+ return {
3020
+ getState,
3021
+ subscribe,
3022
+ setLegend,
3023
+ setVisibility,
3024
+ toggleVisibility,
3025
+ setAll,
3026
+ getLayerToggles
3027
+ };
3028
+ }
3029
+
3030
+ // src/editor/maskToolkit.ts
3031
+ function createMask(width, height, fill = 0) {
3032
+ const data = new Uint8ClampedArray(width * height);
3033
+ if (fill !== 0) data.fill(fill);
3034
+ return { width, height, data };
3035
+ }
3036
+ function cloneMask(mask) {
3037
+ return {
3038
+ width: mask.width,
3039
+ height: mask.height,
3040
+ data: new Uint8ClampedArray(mask.data)
3041
+ };
3042
+ }
3043
+ function clamp(value, min, max) {
3044
+ return Math.min(Math.max(value, min), max);
3045
+ }
3046
+ function buffersEqual(a, b) {
3047
+ if (a === b) return true;
3048
+ if (a.length !== b.length) return false;
3049
+ for (let i = 0; i < a.length; i += 1) {
3050
+ if (a[i] !== b[i]) return false;
3051
+ }
3052
+ return true;
3053
+ }
3054
+ function createMaskEditor(options) {
3055
+ const mask = options.initialMask ? cloneMask(options.initialMask) : createMask(options.width, options.height);
3056
+ let dirty = false;
3057
+ let cleanCheckpoint = new Uint8ClampedArray(mask.data);
3058
+ const undoStack = [];
3059
+ const redoStack = [];
3060
+ const listeners = /* @__PURE__ */ new Set();
3061
+ function snapshot() {
3062
+ return new Uint8ClampedArray(mask.data);
3063
+ }
3064
+ function updateDirtyFlag() {
3065
+ dirty = !buffersEqual(mask.data, cleanCheckpoint);
3066
+ }
3067
+ function emit() {
3068
+ const state = {
3069
+ width: mask.width,
3070
+ height: mask.height,
3071
+ dirty,
3072
+ undoCount: undoStack.length,
3073
+ redoCount: redoStack.length
3074
+ };
3075
+ listeners.forEach((listener) => listener(state));
3076
+ }
3077
+ function drawCircle(cx, cy, radius, delta) {
3078
+ const minX = clamp(Math.floor(cx - radius), 0, mask.width - 1);
3079
+ const maxX = clamp(Math.ceil(cx + radius), 0, mask.width - 1);
3080
+ const minY = clamp(Math.floor(cy - radius), 0, mask.height - 1);
3081
+ const maxY = clamp(Math.ceil(cy + radius), 0, mask.height - 1);
3082
+ const radiusSquared = radius * radius;
3083
+ for (let y = minY; y <= maxY; y += 1) {
3084
+ for (let x = minX; x <= maxX; x += 1) {
3085
+ const dx = x - cx;
3086
+ const dy = y - cy;
3087
+ if (dx * dx + dy * dy > radiusSquared) continue;
3088
+ const index = y * mask.width + x;
3089
+ const next = clamp(mask.data[index] + delta, 0, 255);
3090
+ mask.data[index] = next;
3091
+ }
3092
+ }
3093
+ }
3094
+ function drawStroke(stroke) {
3095
+ if (!stroke.points.length) return;
3096
+ const mode = stroke.mode ?? "paint";
3097
+ const radius = Math.max(1, stroke.radius);
3098
+ const strength = clamp(stroke.strength ?? 1, 0, 1);
3099
+ const delta = Math.round(strength * 255) * (mode === "erase" ? -1 : 1);
3100
+ const points = stroke.points;
3101
+ drawCircle(points[0].x, points[0].y, radius, delta);
3102
+ for (let i = 1; i < points.length; i += 1) {
3103
+ const start = points[i - 1];
3104
+ const end = points[i];
3105
+ const dx = end.x - start.x;
3106
+ const dy = end.y - start.y;
3107
+ const distance = Math.hypot(dx, dy);
3108
+ const step = Math.max(1, Math.ceil(distance / Math.max(1, radius * 0.5)));
3109
+ for (let s = 1; s <= step; s += 1) {
3110
+ const t = s / step;
3111
+ const x = start.x + dx * t;
3112
+ const y = start.y + dy * t;
3113
+ drawCircle(x, y, radius, delta);
3114
+ }
3115
+ }
3116
+ }
3117
+ function applyStroke(stroke) {
3118
+ if (!stroke.points.length) return;
3119
+ undoStack.push(snapshot());
3120
+ redoStack.length = 0;
3121
+ drawStroke(stroke);
3122
+ updateDirtyFlag();
3123
+ emit();
3124
+ }
3125
+ function clear() {
3126
+ undoStack.push(snapshot());
3127
+ redoStack.length = 0;
3128
+ mask.data.fill(0);
3129
+ updateDirtyFlag();
3130
+ emit();
3131
+ }
3132
+ function undo() {
3133
+ if (!undoStack.length) return;
3134
+ redoStack.push(snapshot());
3135
+ const previous = undoStack.pop();
3136
+ if (!previous) return;
3137
+ mask.data.set(previous);
3138
+ updateDirtyFlag();
3139
+ emit();
3140
+ }
3141
+ function redo() {
3142
+ if (!redoStack.length) return;
3143
+ undoStack.push(snapshot());
3144
+ const next = redoStack.pop();
3145
+ if (!next) return;
3146
+ mask.data.set(next);
3147
+ updateDirtyFlag();
3148
+ emit();
3149
+ }
3150
+ function exportMask() {
3151
+ return cloneMask(mask);
3152
+ }
3153
+ function loadMask(next) {
3154
+ undoStack.length = 0;
3155
+ redoStack.length = 0;
3156
+ mask.width = next.width;
3157
+ mask.height = next.height;
3158
+ mask.data = new Uint8ClampedArray(next.data);
3159
+ cleanCheckpoint = new Uint8ClampedArray(mask.data);
3160
+ dirty = false;
3161
+ emit();
3162
+ }
3163
+ function markClean() {
3164
+ cleanCheckpoint = new Uint8ClampedArray(mask.data);
3165
+ dirty = false;
3166
+ emit();
3167
+ }
3168
+ function getState() {
3169
+ return {
3170
+ width: mask.width,
3171
+ height: mask.height,
3172
+ dirty,
3173
+ undoCount: undoStack.length,
3174
+ redoCount: redoStack.length
3175
+ };
3176
+ }
3177
+ function subscribe(listener) {
3178
+ listeners.add(listener);
3179
+ listener(getState());
3180
+ return () => {
3181
+ listeners.delete(listener);
3182
+ };
3183
+ }
3184
+ return {
3185
+ getState,
3186
+ subscribe,
3187
+ applyStroke,
3188
+ clear,
3189
+ undo,
3190
+ redo,
3191
+ exportMask,
3192
+ loadMask,
3193
+ markClean
3194
+ };
3195
+ }
3196
+ export {
3197
+ applyHeightField,
3198
+ buildRimMesh,
3199
+ buildWynArchive,
3200
+ createHeightSampler,
3201
+ createLayerBrowserStore,
3202
+ createMaskEditor,
3203
+ createProjectStore,
3204
+ createTerrainViewerHost,
3205
+ createViewerOverlay,
3206
+ getDefaultTerrainTheme,
3207
+ initTerrainViewer,
3208
+ loadWynArchive,
3209
+ loadWynArchiveFromArrayBuffer,
3210
+ loadWynArchiveFromFile,
3211
+ resolveTerrainTheme,
3212
+ sampleHeightValue
3213
+ };