worldorbit 2.5.15 → 2.5.16

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.
@@ -0,0 +1,3042 @@
1
+ import { cloneAtlasDocument, createEmptyAtlasDocument, formatDocument, getAtlasDocumentNode, loadWorldOrbitSourceWithDiagnostics, materializeAtlasDocument, removeAtlasDocumentNode, resolveAtlasDiagnostics, rotatePoint, upgradeDocumentToV2, validateAtlasDocumentWithDiagnostics, } from "@worldorbit/core";
2
+ import { renderWorldOrbitBlock } from "@worldorbit/markdown";
3
+ import { createInteractiveViewer, } from "@worldorbit/viewer";
4
+ import { getViewerVisibleBounds, invertViewerPoint } from "@worldorbit/viewer/viewer-state";
5
+ const STYLE_ID = "worldorbit-editor-style";
6
+ const SOURCE_INPUT_DEBOUNCE_MS = 120;
7
+ const PREVIEW_BATCH_DELAY_MS = 16;
8
+ const FREE_DISTANCE_PIXEL_FACTOR = 96;
9
+ const AU_IN_KM = 149_597_870.7;
10
+ const EARTH_RADIUS_IN_KM = 6_371;
11
+ const SOLAR_RADIUS_IN_KM = 695_700;
12
+ const PLACEMENT_DIAGNOSTIC_FIELDS = new Set([
13
+ "placement",
14
+ "target",
15
+ "reference",
16
+ "distance",
17
+ "semiMajor",
18
+ "eccentricity",
19
+ "period",
20
+ "angle",
21
+ "inclination",
22
+ "phase",
23
+ "descriptor",
24
+ ]);
25
+ const SURFACE_TARGET_TYPES = new Set([
26
+ "star",
27
+ "planet",
28
+ "moon",
29
+ "asteroid",
30
+ "comet",
31
+ ]);
32
+ const OBJECT_TYPES = [
33
+ "star",
34
+ "planet",
35
+ "moon",
36
+ "belt",
37
+ "asteroid",
38
+ "comet",
39
+ "ring",
40
+ "structure",
41
+ "phenomenon",
42
+ ];
43
+ const OBJECT_STRING_FIELDS = [
44
+ "kind",
45
+ "class",
46
+ "culture",
47
+ "color",
48
+ "image",
49
+ "atmosphere",
50
+ "on",
51
+ "source",
52
+ ];
53
+ const OBJECT_UNIT_FIELDS = [
54
+ "radius",
55
+ "mass",
56
+ "density",
57
+ "gravity",
58
+ "temperature",
59
+ "inner",
60
+ "outer",
61
+ "cycle",
62
+ ];
63
+ const OBJECT_NUMBER_FIELDS = ["albedo"];
64
+ const FIELD_HELP = {
65
+ "defaults-view": {
66
+ description: "Sets the default camera projection for the atlas.",
67
+ references: ["Topdown = map-like", "Isometric = angled overview"],
68
+ },
69
+ "defaults-scale": {
70
+ description: "Chooses the overall spacing/style preset used by the renderer.",
71
+ references: ["diagram = tighter", "presentation = roomier"],
72
+ },
73
+ "defaults-units": {
74
+ description: "Stores a document-wide note about the unit style you want to use.",
75
+ references: ["Example: metric", "Example: lore-standard"],
76
+ },
77
+ "viewpoint-projection": {
78
+ description: "Overrides the projection for this saved viewpoint.",
79
+ references: ["Topdown = flat orbital map", "Isometric = angled scene"],
80
+ },
81
+ "viewpoint-zoom": {
82
+ description: "Controls how closely this viewpoint frames the system.",
83
+ references: ["1 = scene fit", "2+ = close-up"],
84
+ },
85
+ "viewpoint-rotation": {
86
+ description: "Rotates the saved camera angle in degrees.",
87
+ references: ["90deg = quarter turn", "180deg = flip"],
88
+ },
89
+ "placement-target": {
90
+ description: "Names the body or reference this object is attached to.",
91
+ references: ["orbit Primary", "surface Homeworld", "at Naar:L4"],
92
+ },
93
+ "placement-free": {
94
+ description: "Stores a free-placement offset or a descriptive label for loose placement.",
95
+ references: ["8au = far from the star", '"outer system" = descriptive note'],
96
+ },
97
+ "placement-distance": {
98
+ description: "Mean orbit distance from the target body.",
99
+ references: ["1 au = Earth-Sun distance", "384400km = Earth-Moon distance"],
100
+ },
101
+ "placement-semiMajor": {
102
+ description: "Semi-major axis of an orbit, used for elliptical orbits.",
103
+ references: ["1 au = Earth-Sun distance", "Use this instead of distance when orbit shape matters"],
104
+ },
105
+ "placement-eccentricity": {
106
+ description: "Controls how stretched the orbit is. Lower is rounder.",
107
+ references: ["0 = circular orbit", "0.1 = mildly elliptical"],
108
+ },
109
+ "placement-period": {
110
+ description: "How long one orbit takes.",
111
+ references: ["1 y = one Earth year", "27.3d = Moon around Earth"],
112
+ },
113
+ "placement-angle": {
114
+ description: "Rotates the orbit ellipse within the scene.",
115
+ references: ["0deg = default orientation", "90deg = quarter turn"],
116
+ },
117
+ "placement-inclination": {
118
+ description: "Tilts the orbit relative to the main orbital plane.",
119
+ references: ["0deg = same plane", "5deg = slight tilt"],
120
+ },
121
+ "placement-phase": {
122
+ description: "Starting position of the object along its orbit.",
123
+ references: ["0deg = start position", "180deg = opposite side"],
124
+ },
125
+ "prop-radius": {
126
+ description: "Visual body size or real-world-inspired radius value.",
127
+ references: ["1re = Earth radius", "1sol = Sun radius"],
128
+ },
129
+ "prop-mass": {
130
+ description: "Optional mass value for the body.",
131
+ references: ["1me = Earth mass", "1sol = Sun mass"],
132
+ },
133
+ "prop-gravity": {
134
+ description: "Surface gravity or a custom gravity marker.",
135
+ references: ["1g = Earth-like gravity", "0.16g = Moon-like gravity"],
136
+ },
137
+ "prop-temperature": {
138
+ description: "Typical temperature or narrative temperature marker.",
139
+ references: ["288K = Earth-like average", "1200K = very hot world"],
140
+ },
141
+ "prop-atmosphere": {
142
+ description: "Short atmosphere descriptor for the object.",
143
+ references: ['"nitrogen-oxygen"', '"methane haze"'],
144
+ },
145
+ "prop-inner": {
146
+ description: "Inner edge for a belt, ring, or broad phenomenon.",
147
+ references: ["120000km = ring inner edge", "2au = belt starts here"],
148
+ },
149
+ "prop-outer": {
150
+ description: "Outer edge for a belt, ring, or broad phenomenon.",
151
+ references: ["190000km = ring outer edge", "3au = belt ends here"],
152
+ },
153
+ };
154
+ export function createWorldOrbitEditor(container, options = {}) {
155
+ ensureBrowserEnvironment(container);
156
+ installEditorStyles();
157
+ const initial = resolveInitialEditorState(options);
158
+ let atlasDocument = initial.atlasDocument;
159
+ let canonicalSource = initial.source;
160
+ let sourceText = canonicalSource;
161
+ let savedSource = canonicalSource;
162
+ let diagnostics = initial.diagnostics;
163
+ let selection = atlasDocument.objects[0]
164
+ ? { kind: "object", id: atlasDocument.objects[0].id }
165
+ : { kind: "system" };
166
+ const history = [];
167
+ const future = [];
168
+ let dragState = null;
169
+ let ignoreViewerSelection = false;
170
+ let destroyed = false;
171
+ let dirty = false;
172
+ let sourceInputTimer = null;
173
+ let previewTimer = null;
174
+ let lastPreviewSvg = "";
175
+ let lastPreviewMarkup = "";
176
+ const inspectorSectionState = new Map();
177
+ const showTextPane = options.showTextPane ?? true;
178
+ const showInspector = options.showInspector ?? true;
179
+ const showPreview = options.showPreview ?? true;
180
+ const shortcutsEnabled = options.shortcuts ?? true;
181
+ container.classList.add("wo-editor");
182
+ container.dataset.woShowInspector = String(showInspector);
183
+ container.dataset.woShowTextPane = String(showTextPane);
184
+ container.dataset.woShowPreview = String(showPreview);
185
+ container.innerHTML = buildEditorMarkup();
186
+ const toolbar = queryRequired(container, "[data-editor-toolbar]");
187
+ const outline = queryRequired(container, "[data-editor-outline]");
188
+ const stageShell = queryRequired(container, "[data-editor-stage-shell]");
189
+ const stage = queryRequired(container, "[data-editor-stage]");
190
+ const overlay = queryRequired(container, "[data-editor-overlay]");
191
+ const diagnosticsPanel = queryRequired(container, "[data-editor-diagnostics]");
192
+ const sourceDiagnostics = queryRequired(container, "[data-editor-source-diagnostics]");
193
+ const statusBar = queryRequired(container, "[data-editor-status]");
194
+ const liveRegion = queryRequired(container, "[data-editor-live]");
195
+ const inspector = queryRequired(container, "[data-editor-inspector]");
196
+ const sourcePane = queryRequired(container, "[data-editor-source]");
197
+ const previewVisual = queryRequired(container, "[data-editor-preview-visual]");
198
+ const previewMarkup = queryRequired(container, "[data-editor-preview-markup]");
199
+ let viewer = null;
200
+ viewer = createInteractiveViewer(stage, {
201
+ source: canonicalSource,
202
+ width: options.viewerWidth ?? 1120,
203
+ height: options.viewerHeight ?? 680,
204
+ preset: atlasDocument.system?.defaults.preset ?? "atlas-card",
205
+ projection: "document",
206
+ minimap: true,
207
+ tooltipMode: "hover",
208
+ onSelectionChange(selectedObject) {
209
+ if (ignoreViewerSelection || !selectedObject) {
210
+ if (!ignoreViewerSelection && selection?.kind === "object") {
211
+ setSelection(null, false, true);
212
+ }
213
+ return;
214
+ }
215
+ setSelection({ kind: "object", id: selectedObject.objectId }, false, true);
216
+ },
217
+ onViewChange() {
218
+ renderStageOverlay();
219
+ },
220
+ });
221
+ toolbar.addEventListener("click", handleToolbarClick);
222
+ outline.addEventListener("click", handleOutlineClick);
223
+ overlay.addEventListener("pointerdown", handleOverlayPointerDown);
224
+ inspector?.addEventListener("input", handleInspectorInput);
225
+ inspector?.addEventListener("change", handleInspectorChange);
226
+ sourcePane?.addEventListener("input", handleSourceInput);
227
+ sourcePane?.addEventListener("change", handleSourceCommit);
228
+ sourcePane?.addEventListener("blur", handleSourceCommit);
229
+ container.addEventListener("keydown", handleEditorKeyDown);
230
+ window.addEventListener("pointermove", handleWindowPointerMove);
231
+ window.addEventListener("pointerup", handleWindowPointerUp);
232
+ window.addEventListener("pointercancel", handleWindowPointerUp);
233
+ const api = {
234
+ setSource(nextSource) {
235
+ const applied = applySourceText(nextSource, false);
236
+ if (applied) {
237
+ resetHistory();
238
+ renderToolbar();
239
+ }
240
+ },
241
+ setAtlasDocument(document) {
242
+ replaceAtlasDocument(document, false, selection, false);
243
+ resetHistory();
244
+ renderToolbar();
245
+ },
246
+ getSource() {
247
+ return canonicalSource;
248
+ },
249
+ getAtlasDocument() {
250
+ return cloneAtlasDocument(atlasDocument);
251
+ },
252
+ getDiagnostics() {
253
+ return diagnostics.map(cloneResolvedDiagnostic);
254
+ },
255
+ getSelection() {
256
+ return selection ? { path: { ...selection } } : null;
257
+ },
258
+ isDirty() {
259
+ return dirty;
260
+ },
261
+ markSaved() {
262
+ markSavedInternal(true);
263
+ },
264
+ selectPath(path) {
265
+ setSelection(path, true, true);
266
+ },
267
+ canUndo() {
268
+ return history.length > 0;
269
+ },
270
+ canRedo() {
271
+ return future.length > 0;
272
+ },
273
+ undo() {
274
+ const entry = history.pop();
275
+ if (!entry) {
276
+ return false;
277
+ }
278
+ future.unshift(createHistoryEntry());
279
+ restoreHistoryEntry(entry);
280
+ return true;
281
+ },
282
+ redo() {
283
+ const entry = future.shift();
284
+ if (!entry) {
285
+ return false;
286
+ }
287
+ history.push(createHistoryEntry());
288
+ restoreHistoryEntry(entry);
289
+ return true;
290
+ },
291
+ addObject(type = "planet") {
292
+ const id = createUniqueId(type, atlasDocument.objects.map((object) => object.id));
293
+ const created = createNewObject(type, id, atlasDocument);
294
+ const nextDocument = insertObject(atlasDocument, created);
295
+ replaceAtlasDocument(nextDocument, true, { kind: "object", id });
296
+ return id;
297
+ },
298
+ addViewpoint() {
299
+ const id = createUniqueId("viewpoint", atlasDocument.system?.viewpoints.map((viewpoint) => viewpoint.id) ?? []);
300
+ const created = {
301
+ id,
302
+ label: humanizeIdentifier(id),
303
+ summary: "",
304
+ focusObjectId: null,
305
+ selectedObjectId: null,
306
+ projection: atlasDocument.system?.defaults.view ?? "topdown",
307
+ preset: atlasDocument.system?.defaults.preset ?? null,
308
+ zoom: null,
309
+ rotationDeg: 0,
310
+ layers: {},
311
+ filter: null,
312
+ };
313
+ const nextDocument = cloneAtlasDocument(atlasDocument);
314
+ nextDocument.system?.viewpoints.push(created);
315
+ nextDocument.system?.viewpoints.sort((left, right) => left.id.localeCompare(right.id));
316
+ replaceAtlasDocument(nextDocument, true, { kind: "viewpoint", id });
317
+ return id;
318
+ },
319
+ addAnnotation() {
320
+ const id = createUniqueId("annotation", atlasDocument.system?.annotations.map((annotation) => annotation.id) ?? []);
321
+ const created = {
322
+ id,
323
+ label: humanizeIdentifier(id),
324
+ targetObjectId: null,
325
+ body: "",
326
+ tags: [],
327
+ sourceObjectId: null,
328
+ };
329
+ const nextDocument = cloneAtlasDocument(atlasDocument);
330
+ nextDocument.system?.annotations.push(created);
331
+ nextDocument.system?.annotations.sort((left, right) => left.id.localeCompare(right.id));
332
+ replaceAtlasDocument(nextDocument, true, { kind: "annotation", id });
333
+ return id;
334
+ },
335
+ addMetadata(key, value) {
336
+ const metadataKey = key?.trim() || createUniqueId("metadata", Object.keys(atlasDocument.system?.atlasMetadata ?? {}));
337
+ const nextDocument = cloneAtlasDocument(atlasDocument);
338
+ if (nextDocument.system) {
339
+ nextDocument.system.atlasMetadata[metadataKey] = value ?? "";
340
+ }
341
+ replaceAtlasDocument(nextDocument, true, { kind: "metadata", key: metadataKey });
342
+ return metadataKey;
343
+ },
344
+ removeSelection() {
345
+ if (!selection || selection.kind === "system" || selection.kind === "defaults") {
346
+ return false;
347
+ }
348
+ const nextDocument = removeSelectedNode(atlasDocument, selection);
349
+ replaceAtlasDocument(nextDocument, true, { kind: "system" });
350
+ return true;
351
+ },
352
+ exportSvg() {
353
+ return viewer.exportSvg();
354
+ },
355
+ exportEmbedMarkup() {
356
+ return buildEmbedMarkup(getCurrentSourceForExport(), atlasDocument);
357
+ },
358
+ destroy() {
359
+ if (destroyed) {
360
+ return;
361
+ }
362
+ destroyed = true;
363
+ toolbar.removeEventListener("click", handleToolbarClick);
364
+ outline.removeEventListener("click", handleOutlineClick);
365
+ overlay.removeEventListener("pointerdown", handleOverlayPointerDown);
366
+ inspector?.removeEventListener("input", handleInspectorInput);
367
+ inspector?.removeEventListener("change", handleInspectorChange);
368
+ sourcePane?.removeEventListener("input", handleSourceInput);
369
+ sourcePane?.removeEventListener("change", handleSourceCommit);
370
+ sourcePane?.removeEventListener("blur", handleSourceCommit);
371
+ container.removeEventListener("keydown", handleEditorKeyDown);
372
+ window.removeEventListener("pointermove", handleWindowPointerMove);
373
+ window.removeEventListener("pointerup", handleWindowPointerUp);
374
+ window.removeEventListener("pointercancel", handleWindowPointerUp);
375
+ clearSourceInputTimer();
376
+ clearPreviewTimer();
377
+ viewer.destroy();
378
+ container.innerHTML = "";
379
+ container.classList.remove("wo-editor");
380
+ },
381
+ };
382
+ renderAll(true);
383
+ updateDirtyState(true);
384
+ return api;
385
+ function createHistoryEntry() {
386
+ return {
387
+ atlasDocument: cloneAtlasDocument(atlasDocument),
388
+ selection: selection ? { ...selection } : null,
389
+ source: canonicalSource,
390
+ };
391
+ }
392
+ function resetHistory() {
393
+ history.length = 0;
394
+ future.length = 0;
395
+ renderToolbar();
396
+ }
397
+ function clearSourceInputTimer() {
398
+ if (sourceInputTimer !== null) {
399
+ window.clearTimeout(sourceInputTimer);
400
+ sourceInputTimer = null;
401
+ }
402
+ }
403
+ function clearPreviewTimer() {
404
+ if (previewTimer !== null) {
405
+ window.clearTimeout(previewTimer);
406
+ previewTimer = null;
407
+ }
408
+ }
409
+ function getCurrentSourceForExport() {
410
+ if (dragState?.changed) {
411
+ return formatDocument(atlasDocument, { schema: "2.0" });
412
+ }
413
+ return canonicalSource;
414
+ }
415
+ function updateDirtyState(forceEmit = false) {
416
+ const nextDirty = sourceText !== savedSource || canonicalSource !== savedSource || dragState?.changed === true;
417
+ if (!forceEmit && nextDirty === dirty) {
418
+ return;
419
+ }
420
+ dirty = nextDirty;
421
+ renderStatusBar();
422
+ options.onDirtyChange?.(dirty);
423
+ }
424
+ function markSavedInternal(emit = false) {
425
+ sourceText = canonicalSource;
426
+ renderSourcePane();
427
+ savedSource = canonicalSource;
428
+ updateDirtyState(emit);
429
+ }
430
+ function restoreHistoryEntry(entry) {
431
+ clearSourceInputTimer();
432
+ atlasDocument = cloneAtlasDocument(entry.atlasDocument);
433
+ canonicalSource = entry.source;
434
+ sourceText = canonicalSource;
435
+ diagnostics = collectDocumentDiagnostics(atlasDocument);
436
+ selection = normalizeSelection(entry.selection);
437
+ syncViewer({ preserveCamera: true, applyViewpointSelection: selection?.kind === "viewpoint" });
438
+ setSelection(selection, false, false);
439
+ renderAll();
440
+ updateDirtyState();
441
+ emitSnapshot();
442
+ }
443
+ function replaceAtlasDocument(nextDocument, pushHistory, nextSelection, preserveSourceText = false) {
444
+ if (pushHistory) {
445
+ history.push(createHistoryEntry());
446
+ future.length = 0;
447
+ }
448
+ clearSourceInputTimer();
449
+ atlasDocument = cloneAtlasDocument(nextDocument);
450
+ canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
451
+ if (!preserveSourceText) {
452
+ sourceText = canonicalSource;
453
+ }
454
+ diagnostics = collectDocumentDiagnostics(atlasDocument);
455
+ selection = normalizeSelection(nextSelection);
456
+ syncViewer({
457
+ preserveCamera: selection?.kind !== "viewpoint",
458
+ applyViewpointSelection: selection?.kind === "viewpoint",
459
+ });
460
+ setSelection(selection, false, false);
461
+ renderAll();
462
+ updateDirtyState();
463
+ emitSnapshot();
464
+ }
465
+ function applySourceText(nextSourceText, commitHistory) {
466
+ sourceText = nextSourceText;
467
+ if (sourcePane && sourcePane.value !== nextSourceText) {
468
+ sourcePane.value = nextSourceText;
469
+ }
470
+ const loaded = loadWorldOrbitSourceWithDiagnostics(nextSourceText);
471
+ if (!loaded.ok || !loaded.value) {
472
+ diagnostics = loaded.diagnostics.map((diagnostic) => ({
473
+ diagnostic,
474
+ path: null,
475
+ }));
476
+ renderDiagnostics();
477
+ renderSourceDiagnostics();
478
+ renderOutline();
479
+ renderInspector();
480
+ renderStatusBar();
481
+ updateLiveRegion();
482
+ updateDirtyState();
483
+ options.onDiagnosticsChange?.(diagnostics.map(cloneResolvedDiagnostic));
484
+ return false;
485
+ }
486
+ const nextDocument = loaded.value.atlasDocument ?? upgradeDocumentToV2(loaded.value.document);
487
+ const loadedDiagnostics = resolveAtlasDiagnostics(nextDocument, loaded.diagnostics);
488
+ if (commitHistory) {
489
+ history.push(createHistoryEntry());
490
+ future.length = 0;
491
+ sourceText = formatDocument(nextDocument, { schema: "2.0" });
492
+ }
493
+ atlasDocument = cloneAtlasDocument(nextDocument);
494
+ canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
495
+ diagnostics = mergeDiagnostics(loadedDiagnostics, collectDocumentDiagnostics(atlasDocument));
496
+ selection = normalizeSelection(selection);
497
+ syncViewer({
498
+ preserveCamera: selection?.kind !== "viewpoint",
499
+ applyViewpointSelection: selection?.kind === "viewpoint",
500
+ });
501
+ renderAll();
502
+ updateDirtyState();
503
+ emitSnapshot();
504
+ return true;
505
+ }
506
+ function syncViewer(options = {}) {
507
+ if (!viewer) {
508
+ return;
509
+ }
510
+ const previousState = viewer.getState();
511
+ const currentRenderOptions = viewer.getRenderOptions();
512
+ const nextPreset = atlasDocument.system?.defaults.preset ?? "atlas-card";
513
+ ignoreViewerSelection = true;
514
+ if (currentRenderOptions.preset !== nextPreset || currentRenderOptions.projection !== "document") {
515
+ viewer.setRenderOptions({
516
+ preset: nextPreset,
517
+ projection: "document",
518
+ });
519
+ }
520
+ viewer.setDocument(materializeAtlasDocument(atlasDocument));
521
+ if (options.applyViewpointSelection && selection?.kind === "viewpoint" && selection.id) {
522
+ viewer.goToViewpoint(selection.id);
523
+ }
524
+ else if (options.preserveCamera !== false) {
525
+ viewer.setState({
526
+ ...previousState,
527
+ selectedObjectId: selection?.kind === "object" ? selection.id ?? null : null,
528
+ });
529
+ }
530
+ else if (selection?.kind === "object" && selection.id) {
531
+ viewer.focusObject(selection.id);
532
+ }
533
+ ignoreViewerSelection = false;
534
+ }
535
+ function emitSnapshot() {
536
+ const snapshot = {
537
+ source: canonicalSource,
538
+ atlasDocument: cloneAtlasDocument(atlasDocument),
539
+ diagnostics: diagnostics.map(cloneResolvedDiagnostic),
540
+ selection: selection ? { path: { ...selection } } : null,
541
+ };
542
+ options.onDiagnosticsChange?.(snapshot.diagnostics);
543
+ options.onSelectionChange?.(snapshot.selection);
544
+ options.onChange?.(snapshot);
545
+ }
546
+ function setSelection(nextSelection, syncViewerSelection, emit = true) {
547
+ selection = normalizeSelection(nextSelection);
548
+ if (syncViewerSelection) {
549
+ ignoreViewerSelection = true;
550
+ if (selection?.kind === "object" && selection.id) {
551
+ viewer.focusObject(selection.id);
552
+ }
553
+ else if (selection?.kind === "viewpoint" && selection.id) {
554
+ viewer.goToViewpoint(selection.id);
555
+ }
556
+ ignoreViewerSelection = false;
557
+ }
558
+ renderToolbar();
559
+ renderOutline();
560
+ if (!inspector?.contains(document.activeElement)) {
561
+ renderInspector();
562
+ }
563
+ renderStageOverlay();
564
+ renderStatusBar();
565
+ updateLiveRegion();
566
+ if (emit) {
567
+ options.onSelectionChange?.(selection ? { path: { ...selection } } : null);
568
+ }
569
+ }
570
+ function normalizeSelection(nextSelection) {
571
+ if (!nextSelection) {
572
+ return null;
573
+ }
574
+ const node = getAtlasDocumentNode(atlasDocument, nextSelection);
575
+ if (node === null || node === undefined) {
576
+ return nextSelection.kind === "system" || nextSelection.kind === "defaults"
577
+ ? nextSelection
578
+ : null;
579
+ }
580
+ return nextSelection;
581
+ }
582
+ function renderAll(immediatePreview = false) {
583
+ renderToolbar();
584
+ renderOutline();
585
+ renderDiagnostics();
586
+ renderSourceDiagnostics();
587
+ // Do not rebuild the inspector DOM if the user is currently typing in it,
588
+ // to prevent losing focus/cursor position and making input glitchy.
589
+ if (!inspector?.contains(document.activeElement)) {
590
+ renderInspector();
591
+ }
592
+ renderSourcePane();
593
+ renderPreview(immediatePreview);
594
+ renderStageOverlay();
595
+ renderStatusBar();
596
+ updateLiveRegion();
597
+ }
598
+ function renderToolbar() {
599
+ const objectType = toolbar.querySelector("[data-editor-add-object-type]")
600
+ ?.value ?? "planet";
601
+ toolbar.innerHTML = `
602
+ <div class="wo-editor-toolbar-group">
603
+ <select data-editor-add-object-type>
604
+ ${OBJECT_TYPES.map((type) => `<option value="${escapeHtml(type)}"${type === objectType ? " selected" : ""}>${escapeHtml(humanizeIdentifier(type))}</option>`).join("")}
605
+ </select>
606
+ <button type="button" data-editor-action="add-object">Add object</button>
607
+ <button type="button" data-editor-action="add-viewpoint">Add viewpoint</button>
608
+ <button type="button" data-editor-action="add-annotation">Add annotation</button>
609
+ <button type="button" data-editor-action="add-metadata">Add metadata</button>
610
+ </div>
611
+ <div class="wo-editor-toolbar-group">
612
+ <button type="button" data-editor-action="remove"${!selection || selection.kind === "system" || selection.kind === "defaults" ? " disabled" : ""}>Remove</button>
613
+ <button type="button" data-editor-action="undo"${history.length === 0 ? " disabled" : ""}>Undo</button>
614
+ <button type="button" data-editor-action="redo"${future.length === 0 ? " disabled" : ""}>Redo</button>
615
+ <button type="button" data-editor-action="format">Format source</button>
616
+ </div>
617
+ `;
618
+ }
619
+ function renderOutline() {
620
+ const activeKey = selectionKey(selection);
621
+ const diagnosticBuckets = buildDiagnosticBuckets(diagnostics);
622
+ const metadataEntries = Object.entries(atlasDocument.system?.atlasMetadata ?? {}).sort(([left], [right]) => left.localeCompare(right));
623
+ outline.innerHTML = `
624
+ <div class="wo-editor-outline-section">
625
+ <h3>Atlas</h3>
626
+ ${renderOutlineButton({ kind: "system" }, "System", activeKey, diagnosticBuckets)}
627
+ ${renderOutlineButton({ kind: "defaults" }, "Defaults", activeKey, diagnosticBuckets)}
628
+ </div>
629
+ <div class="wo-editor-outline-section">
630
+ <h3>Metadata</h3>
631
+ ${metadataEntries.length > 0
632
+ ? metadataEntries
633
+ .map(([key]) => renderOutlineButton({ kind: "metadata", key }, key, activeKey, diagnosticBuckets))
634
+ .join("")
635
+ : `<p class="wo-editor-empty">No atlas metadata yet.</p>`}
636
+ </div>
637
+ <div class="wo-editor-outline-section">
638
+ <h3>Viewpoints</h3>
639
+ ${(atlasDocument.system?.viewpoints.length ?? 0) > 0
640
+ ? atlasDocument.system?.viewpoints
641
+ .map((viewpoint) => renderOutlineButton({ kind: "viewpoint", id: viewpoint.id }, viewpoint.label, activeKey, diagnosticBuckets))
642
+ .join("")
643
+ : `<p class="wo-editor-empty">No viewpoints yet.</p>`}
644
+ </div>
645
+ <div class="wo-editor-outline-section">
646
+ <h3>Annotations</h3>
647
+ ${(atlasDocument.system?.annotations.length ?? 0) > 0
648
+ ? atlasDocument.system?.annotations
649
+ .map((annotation) => renderOutlineButton({ kind: "annotation", id: annotation.id }, annotation.label, activeKey, diagnosticBuckets))
650
+ .join("")
651
+ : `<p class="wo-editor-empty">No annotations yet.</p>`}
652
+ </div>
653
+ <div class="wo-editor-outline-section">
654
+ <h3>Objects</h3>
655
+ ${atlasDocument.objects.length > 0
656
+ ? atlasDocument.objects
657
+ .map((object) => renderOutlineButton({ kind: "object", id: object.id }, `${object.id} - ${object.type}`, activeKey, diagnosticBuckets))
658
+ .join("")
659
+ : `<p class="wo-editor-empty">No objects yet.</p>`}
660
+ </div>
661
+ `;
662
+ }
663
+ function renderDiagnostics() {
664
+ diagnosticsPanel.innerHTML =
665
+ diagnostics.length === 0
666
+ ? `<p class="wo-editor-empty">No diagnostics.</p>`
667
+ : diagnostics
668
+ .map((entry) => {
669
+ const pathLabel = entry.path ? describePath(entry.path) : "Source";
670
+ const location = formatDiagnosticLocation(entry);
671
+ return `<article class="wo-editor-diagnostic wo-editor-diagnostic-${escapeHtml(entry.diagnostic.severity)}">
672
+ <strong>${escapeHtml(entry.diagnostic.severity.toUpperCase())}</strong>
673
+ <span>${escapeHtml(pathLabel)}${location ? ` · ${escapeHtml(location)}` : ""}</span>
674
+ <p>${escapeHtml(entry.diagnostic.message)}</p>
675
+ </article>`;
676
+ })
677
+ .join("");
678
+ }
679
+ function renderSourceDiagnostics() {
680
+ const sourceEntries = diagnostics.filter((entry) => !entry.path || entry.diagnostic.line !== undefined);
681
+ sourceDiagnostics.innerHTML =
682
+ sourceEntries.length === 0
683
+ ? `<p class="wo-editor-empty">No source diagnostics.</p>`
684
+ : sourceEntries
685
+ .map((entry) => {
686
+ const location = formatDiagnosticLocation(entry) ?? "Source";
687
+ return `<article class="wo-editor-diagnostic wo-editor-diagnostic-${escapeHtml(entry.diagnostic.severity)}">
688
+ <strong>${escapeHtml(entry.diagnostic.severity.toUpperCase())}</strong>
689
+ <span>${escapeHtml(location)}</span>
690
+ <p>${escapeHtml(entry.diagnostic.message)}</p>
691
+ </article>`;
692
+ })
693
+ .join("");
694
+ }
695
+ function renderInspector() {
696
+ if (!inspector) {
697
+ return;
698
+ }
699
+ captureInspectorSectionState(inspector, inspectorSectionState);
700
+ const formState = {
701
+ selection: selection ? { path: { ...selection } } : null,
702
+ system: atlasDocument.system,
703
+ viewpoints: atlasDocument.system?.viewpoints ?? [],
704
+ objects: atlasDocument.objects,
705
+ };
706
+ if (!selection) {
707
+ inspector.innerHTML = `<p class="wo-editor-empty">Select an atlas node or object to edit it.</p>`;
708
+ return;
709
+ }
710
+ const diagnosticSummary = renderInspectorDiagnosticSummary(selection, diagnostics);
711
+ switch (selection.kind) {
712
+ case "system":
713
+ inspector.innerHTML = diagnosticSummary + renderSystemInspector(formState);
714
+ applyInspectorSectionState(inspector, inspectorSectionState);
715
+ decorateInspectorDiagnostics(selection, diagnostics);
716
+ return;
717
+ case "defaults":
718
+ inspector.innerHTML = diagnosticSummary + renderDefaultsInspector(formState);
719
+ applyInspectorSectionState(inspector, inspectorSectionState);
720
+ decorateInspectorDiagnostics(selection, diagnostics);
721
+ return;
722
+ case "metadata":
723
+ inspector.innerHTML = diagnosticSummary + renderMetadataInspector(formState, selection.key ?? "");
724
+ applyInspectorSectionState(inspector, inspectorSectionState);
725
+ decorateInspectorDiagnostics(selection, diagnostics);
726
+ return;
727
+ case "viewpoint":
728
+ inspector.innerHTML = diagnosticSummary + renderViewpointInspector(formState, selection.id ?? "");
729
+ applyInspectorSectionState(inspector, inspectorSectionState);
730
+ decorateInspectorDiagnostics(selection, diagnostics);
731
+ return;
732
+ case "annotation":
733
+ inspector.innerHTML = diagnosticSummary + renderAnnotationInspector(formState, selection.id ?? "");
734
+ applyInspectorSectionState(inspector, inspectorSectionState);
735
+ decorateInspectorDiagnostics(selection, diagnostics);
736
+ return;
737
+ case "object":
738
+ inspector.innerHTML = diagnosticSummary + renderObjectInspector(formState, selection.id ?? "");
739
+ applyInspectorSectionState(inspector, inspectorSectionState);
740
+ decorateInspectorDiagnostics(selection, diagnostics);
741
+ return;
742
+ }
743
+ }
744
+ function renderSourcePane() {
745
+ if (!sourcePane) {
746
+ return;
747
+ }
748
+ if (sourcePane.value !== sourceText) {
749
+ sourcePane.value = sourceText;
750
+ }
751
+ }
752
+ function renderPreview(immediate = false) {
753
+ if (immediate) {
754
+ renderPreviewNow();
755
+ return;
756
+ }
757
+ schedulePreviewRender();
758
+ }
759
+ function renderStageOverlay() {
760
+ if (!viewer) {
761
+ return;
762
+ }
763
+ overlay.innerHTML = "";
764
+ if (selection?.kind !== "object" || !selection.id) {
765
+ return;
766
+ }
767
+ const details = viewer.getObjectDetails(selection.id);
768
+ if (!details) {
769
+ return;
770
+ }
771
+ renderHintMarker(details.renderObject.x, details.renderObject.y, details.renderObject.objectId);
772
+ if (details.parent) {
773
+ renderHintMarker(details.parent.x, details.parent.y, `Parent: ${details.parent.objectId}`, true);
774
+ }
775
+ if (details.renderObject.anchorX !== undefined && details.renderObject.anchorY !== undefined) {
776
+ renderHintMarker(details.renderObject.anchorX, details.renderObject.anchorY, "Anchor", true);
777
+ }
778
+ if (details.object.placement?.mode === "orbit" && details.orbit) {
779
+ const phasePoint = projectStagePoint({
780
+ x: details.renderObject.x,
781
+ y: details.renderObject.y,
782
+ });
783
+ overlay.append(createHandleElement("orbit-phase", details.objectId, phasePoint, "Phase"));
784
+ const axisPoint = projectOrbitRadiusHandle(details);
785
+ overlay.append(createHandleElement("orbit-radius", details.objectId, axisPoint, "Size"));
786
+ }
787
+ if (details.object.placement?.mode === "at") {
788
+ overlay.append(createHandleElement("at-reference", details.objectId, projectStagePoint({ x: details.renderObject.x, y: details.renderObject.y }), "Reference"));
789
+ const badge = document.createElement("div");
790
+ badge.className = "wo-editor-hint wo-editor-hint-note";
791
+ badge.textContent = "Drag to an object center or nearby Lagrange point.";
792
+ overlay.append(badge);
793
+ }
794
+ if (details.object.placement?.mode === "surface") {
795
+ overlay.append(createHandleElement("surface-target", details.objectId, projectStagePoint({ x: details.renderObject.x, y: details.renderObject.y }), "Surface"));
796
+ const badge = document.createElement("div");
797
+ badge.className = "wo-editor-hint wo-editor-hint-note";
798
+ badge.textContent = "Drag onto another surface-capable body.";
799
+ overlay.append(badge);
800
+ }
801
+ if (details.object.placement?.mode === "free") {
802
+ overlay.append(createHandleElement("free-distance", details.objectId, projectStagePoint({ x: details.renderObject.x, y: details.renderObject.y }), "Offset"));
803
+ const badge = document.createElement("div");
804
+ badge.className = "wo-editor-hint wo-editor-hint-note";
805
+ badge.textContent = "Drag horizontally to change free offset.";
806
+ overlay.append(badge);
807
+ }
808
+ const placementDiagnostics = getPlacementDiagnosticsForSelection(selection, diagnostics).slice(0, 3);
809
+ if (placementDiagnostics.length > 0) {
810
+ const panel = document.createElement("div");
811
+ panel.className = "wo-editor-overlay-diagnostics";
812
+ panel.setAttribute("role", "status");
813
+ panel.setAttribute("aria-live", "polite");
814
+ panel.innerHTML = placementDiagnostics
815
+ .map((entry) => `<article class="wo-editor-overlay-diagnostic wo-editor-overlay-diagnostic-${escapeHtml(entry.diagnostic.severity)}">
816
+ <strong>${escapeHtml(entry.diagnostic.severity.toUpperCase())}</strong>
817
+ <p>${escapeHtml(entry.diagnostic.message)}</p>
818
+ </article>`)
819
+ .join("");
820
+ overlay.append(panel);
821
+ }
822
+ }
823
+ function renderHintMarker(worldX, worldY, label, subtle = false) {
824
+ const point = projectStagePoint({ x: worldX, y: worldY });
825
+ const marker = document.createElement("div");
826
+ marker.className = `wo-editor-hint${subtle ? " is-subtle" : ""}`;
827
+ marker.style.left = `${point.x}px`;
828
+ marker.style.top = `${point.y}px`;
829
+ marker.textContent = label;
830
+ overlay.append(marker);
831
+ }
832
+ function projectOrbitRadiusHandle(details) {
833
+ const orbit = details.orbit;
834
+ if (!orbit) {
835
+ return projectStagePoint({ x: details.renderObject.x, y: details.renderObject.y });
836
+ }
837
+ const localPoint = {
838
+ x: orbit.cx + (orbit.kind === "circle" ? orbit.radius ?? 0 : orbit.rx ?? 0),
839
+ y: orbit.cy,
840
+ };
841
+ const rotatedPoint = rotatePoint(localPoint, { x: orbit.cx, y: orbit.cy }, orbit.rotationDeg);
842
+ return projectStagePoint(rotatedPoint);
843
+ }
844
+ function projectStagePoint(point) {
845
+ const scene = viewer.getScene();
846
+ const state = viewer.getState();
847
+ const center = {
848
+ x: scene.width / 2,
849
+ y: scene.height / 2,
850
+ };
851
+ const rotated = rotatePoint(point, center, state.rotationDeg);
852
+ const viewportPoint = {
853
+ x: center.x + (rotated.x - center.x) * state.scale + state.translateX,
854
+ y: center.y + (rotated.y - center.y) * state.scale + state.translateY,
855
+ };
856
+ const svg = stage.querySelector("svg");
857
+ if (!svg) {
858
+ return viewportPoint;
859
+ }
860
+ const svgRect = svg.getBoundingClientRect();
861
+ const stageRect = stageShell.getBoundingClientRect();
862
+ return {
863
+ x: svgRect.left -
864
+ stageRect.left +
865
+ (viewportPoint.x / Math.max(scene.width, 1)) * svgRect.width,
866
+ y: svgRect.top -
867
+ stageRect.top +
868
+ (viewportPoint.y / Math.max(scene.height, 1)) * svgRect.height,
869
+ };
870
+ }
871
+ function handleToolbarClick(event) {
872
+ const button = event.target?.closest("[data-editor-action]");
873
+ if (!button) {
874
+ return;
875
+ }
876
+ switch (button.dataset.editorAction) {
877
+ case "add-object": {
878
+ const type = toolbar.querySelector("[data-editor-add-object-type]")
879
+ ?.value;
880
+ api.addObject(type ?? "planet");
881
+ return;
882
+ }
883
+ case "add-viewpoint":
884
+ api.addViewpoint();
885
+ return;
886
+ case "add-annotation":
887
+ api.addAnnotation();
888
+ return;
889
+ case "add-metadata":
890
+ api.addMetadata();
891
+ return;
892
+ case "remove":
893
+ api.removeSelection();
894
+ return;
895
+ case "undo":
896
+ api.undo();
897
+ return;
898
+ case "redo":
899
+ api.redo();
900
+ return;
901
+ case "format":
902
+ clearSourceInputTimer();
903
+ sourceText = canonicalSource;
904
+ renderSourcePane();
905
+ updateDirtyState();
906
+ return;
907
+ }
908
+ }
909
+ function handleOutlineClick(event) {
910
+ const button = event.target?.closest("[data-path-kind]");
911
+ if (!button) {
912
+ return;
913
+ }
914
+ setSelection({
915
+ kind: button.dataset.pathKind,
916
+ id: button.dataset.pathId || undefined,
917
+ key: button.dataset.pathKey || undefined,
918
+ }, true, true);
919
+ }
920
+ function handleInspectorInput() {
921
+ applyInspectorState(false);
922
+ }
923
+ function handleInspectorChange() {
924
+ applyInspectorState(true);
925
+ }
926
+ function applyInspectorState(commitHistory) {
927
+ if (!selection || !inspector) {
928
+ return;
929
+ }
930
+ switch (selection.kind) {
931
+ case "system":
932
+ replaceAtlasDocument(buildSystemDocumentFromInspector(), commitHistory, selection, false);
933
+ return;
934
+ case "defaults":
935
+ replaceAtlasDocument(buildDefaultsDocumentFromInspector(), commitHistory, selection, false);
936
+ return;
937
+ case "metadata":
938
+ replaceAtlasDocument(buildMetadataDocumentFromInspector(selection.key ?? ""), commitHistory, selection, false);
939
+ return;
940
+ case "viewpoint":
941
+ replaceAtlasDocument(buildViewpointDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
942
+ return;
943
+ case "annotation":
944
+ replaceAtlasDocument(buildAnnotationDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
945
+ return;
946
+ case "object":
947
+ replaceAtlasDocument(buildObjectDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
948
+ return;
949
+ }
950
+ }
951
+ function handleSourceInput() {
952
+ sourceText = sourcePane?.value ?? "";
953
+ updateDirtyState();
954
+ clearSourceInputTimer();
955
+ sourceInputTimer = window.setTimeout(() => {
956
+ sourceInputTimer = null;
957
+ applySourceText(sourceText, false);
958
+ }, SOURCE_INPUT_DEBOUNCE_MS);
959
+ }
960
+ function handleSourceCommit() {
961
+ clearSourceInputTimer();
962
+ const committed = applySourceText(sourcePane?.value ?? "", true);
963
+ if (committed) {
964
+ renderSourcePane();
965
+ }
966
+ }
967
+ function handleEditorKeyDown(event) {
968
+ if (event.key === "Escape" && dragState) {
969
+ event.preventDefault();
970
+ cancelActiveDrag();
971
+ return;
972
+ }
973
+ if (!shortcutsEnabled || event.defaultPrevented || shouldIgnoreShortcutEvent(event)) {
974
+ return;
975
+ }
976
+ const isUndo = (event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z";
977
+ const isRedo = (event.ctrlKey || event.metaKey) &&
978
+ ((event.shiftKey && event.key.toLowerCase() === "z") || event.key.toLowerCase() === "y");
979
+ if (isUndo) {
980
+ event.preventDefault();
981
+ api.undo();
982
+ return;
983
+ }
984
+ if (isRedo) {
985
+ event.preventDefault();
986
+ api.redo();
987
+ }
988
+ }
989
+ function handleOverlayPointerDown(event) {
990
+ const handle = event.target?.closest("[data-handle-kind]");
991
+ if (!handle) {
992
+ return;
993
+ }
994
+ const objectId = handle.dataset.objectId;
995
+ const kind = handle.dataset.handleKind;
996
+ if (!objectId ||
997
+ !["orbit-phase", "orbit-radius", "at-reference", "surface-target", "free-distance"].includes(kind)) {
998
+ return;
999
+ }
1000
+ clearSourceInputTimer();
1001
+ if (sourceText !== canonicalSource) {
1002
+ const committed = applySourceText(sourceText, true);
1003
+ if (!committed) {
1004
+ event.preventDefault();
1005
+ return;
1006
+ }
1007
+ }
1008
+ const details = viewer?.getObjectDetails(objectId) ?? null;
1009
+ dragState = {
1010
+ kind,
1011
+ objectId,
1012
+ pointerId: event.pointerId,
1013
+ startedFrom: createHistoryEntry(),
1014
+ changed: false,
1015
+ orbitRadiusContext: kind === "orbit-radius" && details
1016
+ ? createOrbitRadiusDragContext(atlasDocument, viewer.getScene(), details)
1017
+ : null,
1018
+ };
1019
+ handle.setPointerCapture?.(event.pointerId);
1020
+ event.preventDefault();
1021
+ }
1022
+ function handleWindowPointerMove(event) {
1023
+ if (!dragState ||
1024
+ dragState.pointerId !== event.pointerId ||
1025
+ selection?.kind !== "object" ||
1026
+ selection.id !== dragState.objectId) {
1027
+ return;
1028
+ }
1029
+ const details = viewer.getObjectDetails(dragState.objectId);
1030
+ if (!details) {
1031
+ return;
1032
+ }
1033
+ const pointer = getWorldPointFromClient(event.clientX, event.clientY);
1034
+ let nextDocument = atlasDocument;
1035
+ switch (dragState.kind) {
1036
+ case "orbit-phase":
1037
+ if (details.object.placement?.mode === "orbit" && details.orbit) {
1038
+ nextDocument = updateOrbitPhase(atlasDocument, dragState.objectId, details, pointer);
1039
+ }
1040
+ break;
1041
+ case "orbit-radius":
1042
+ if (details.object.placement?.mode === "orbit" && details.orbit) {
1043
+ nextDocument = updateOrbitRadius(atlasDocument, dragState.objectId, details, pointer, dragState.orbitRadiusContext ?? null);
1044
+ }
1045
+ break;
1046
+ case "at-reference":
1047
+ if (details.object.placement?.mode === "at") {
1048
+ nextDocument = updateAtReference(atlasDocument, dragState.objectId, viewer.getScene(), pointer);
1049
+ }
1050
+ break;
1051
+ case "surface-target":
1052
+ if (details.object.placement?.mode === "surface") {
1053
+ nextDocument = updateSurfaceTarget(atlasDocument, dragState.objectId, viewer.getScene(), pointer);
1054
+ }
1055
+ break;
1056
+ case "free-distance":
1057
+ if (details.object.placement?.mode === "free") {
1058
+ nextDocument = updateFreeDistance(atlasDocument, dragState.objectId, viewer.getScene(), details, pointer);
1059
+ }
1060
+ break;
1061
+ }
1062
+ if (nextDocument === atlasDocument) {
1063
+ return;
1064
+ }
1065
+ dragState.changed = true;
1066
+ atlasDocument = cloneAtlasDocument(nextDocument);
1067
+ diagnostics = collectDocumentDiagnostics(atlasDocument);
1068
+ syncViewer({ preserveCamera: true, applyViewpointSelection: false });
1069
+ renderDiagnostics();
1070
+ renderSourceDiagnostics();
1071
+ renderOutline();
1072
+ renderInspector();
1073
+ renderStageOverlay();
1074
+ renderStatusBar();
1075
+ updateLiveRegion();
1076
+ schedulePreviewRender();
1077
+ updateDirtyState();
1078
+ }
1079
+ function handleWindowPointerUp(event) {
1080
+ if (!dragState || dragState.pointerId !== event.pointerId) {
1081
+ return;
1082
+ }
1083
+ const completedDrag = dragState;
1084
+ if (!dragState.changed) {
1085
+ dragState = null;
1086
+ return;
1087
+ }
1088
+ history.push(dragState.startedFrom);
1089
+ future.length = 0;
1090
+ canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
1091
+ sourceText = canonicalSource;
1092
+ dragState = null;
1093
+ renderAll();
1094
+ if (completedDrag.kind === "orbit-radius") {
1095
+ maybeFitOrbitResizeInView(completedDrag.objectId);
1096
+ }
1097
+ updateDirtyState();
1098
+ emitSnapshot();
1099
+ }
1100
+ function cancelActiveDrag() {
1101
+ if (!dragState) {
1102
+ return;
1103
+ }
1104
+ const startedFrom = dragState.startedFrom;
1105
+ dragState = null;
1106
+ atlasDocument = cloneAtlasDocument(startedFrom.atlasDocument);
1107
+ canonicalSource = startedFrom.source;
1108
+ sourceText = canonicalSource;
1109
+ diagnostics = collectDocumentDiagnostics(atlasDocument);
1110
+ setSelection(startedFrom.selection, false, false);
1111
+ syncViewer({ preserveCamera: true, applyViewpointSelection: selection?.kind === "viewpoint" });
1112
+ renderAll();
1113
+ updateDirtyState();
1114
+ emitSnapshot();
1115
+ }
1116
+ function getWorldPointFromClient(clientX, clientY) {
1117
+ const svg = stage.querySelector("svg");
1118
+ const scene = viewer.getScene();
1119
+ if (!svg) {
1120
+ return { x: scene.width / 2, y: scene.height / 2 };
1121
+ }
1122
+ const rect = svg.getBoundingClientRect();
1123
+ const viewportPoint = {
1124
+ x: ((clientX - rect.left) / Math.max(rect.width, 1)) * scene.width,
1125
+ y: ((clientY - rect.top) / Math.max(rect.height, 1)) * scene.height,
1126
+ };
1127
+ return invertViewerPoint(scene, viewer.getState(), viewportPoint);
1128
+ }
1129
+ function buildSystemDocumentFromInspector() {
1130
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1131
+ const form = inspector?.querySelector("form[data-editor-form='system']");
1132
+ if (!form || !nextDocument.system) {
1133
+ return nextDocument;
1134
+ }
1135
+ nextDocument.system.id = readTextInput(form, "system-id") || nextDocument.system.id;
1136
+ nextDocument.system.title = readOptionalTextInput(form, "system-title");
1137
+ return nextDocument;
1138
+ }
1139
+ function buildDefaultsDocumentFromInspector() {
1140
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1141
+ const form = inspector?.querySelector("form[data-editor-form='defaults']");
1142
+ if (!form || !nextDocument.system) {
1143
+ return nextDocument;
1144
+ }
1145
+ nextDocument.system.defaults.view =
1146
+ readTextInput(form, "defaults-view") || "topdown";
1147
+ nextDocument.system.defaults.scale = readOptionalTextInput(form, "defaults-scale");
1148
+ nextDocument.system.defaults.units = readOptionalTextInput(form, "defaults-units");
1149
+ nextDocument.system.defaults.preset =
1150
+ readOptionalTextInput(form, "defaults-preset") ?? null;
1151
+ nextDocument.system.defaults.theme = readOptionalTextInput(form, "defaults-theme");
1152
+ return nextDocument;
1153
+ }
1154
+ function buildMetadataDocumentFromInspector(currentKey) {
1155
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1156
+ const form = inspector?.querySelector("form[data-editor-form='metadata']");
1157
+ if (!form || !nextDocument.system) {
1158
+ return nextDocument;
1159
+ }
1160
+ const nextKey = readTextInput(form, "metadata-key") || currentKey;
1161
+ const nextValue = readOptionalTextInput(form, "metadata-value") ?? "";
1162
+ if (nextKey !== currentKey) {
1163
+ delete nextDocument.system.atlasMetadata[currentKey];
1164
+ nextDocument.system.atlasMetadata[nextKey] = nextValue;
1165
+ selection = { kind: "metadata", key: nextKey };
1166
+ return nextDocument;
1167
+ }
1168
+ nextDocument.system.atlasMetadata[currentKey] = nextValue;
1169
+ return nextDocument;
1170
+ }
1171
+ function buildViewpointDocumentFromInspector(currentId) {
1172
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1173
+ const form = inspector?.querySelector("form[data-editor-form='viewpoint']");
1174
+ const current = nextDocument.system?.viewpoints.find((viewpoint) => viewpoint.id === currentId);
1175
+ if (!form || !current || !nextDocument.system) {
1176
+ return nextDocument;
1177
+ }
1178
+ const nextId = readTextInput(form, "viewpoint-id") || current.id;
1179
+ const replacement = {
1180
+ ...current,
1181
+ id: nextId,
1182
+ label: readTextInput(form, "viewpoint-label") || current.label,
1183
+ summary: readOptionalTextInput(form, "viewpoint-summary") ?? "",
1184
+ focusObjectId: readOptionalTextInput(form, "viewpoint-focus"),
1185
+ selectedObjectId: readOptionalTextInput(form, "viewpoint-select"),
1186
+ projection: readTextInput(form, "viewpoint-projection") ||
1187
+ current.projection,
1188
+ preset: readOptionalTextInput(form, "viewpoint-preset") ??
1189
+ null,
1190
+ zoom: parseNullableNumber(readOptionalTextInput(form, "viewpoint-zoom")),
1191
+ rotationDeg: parseNullableNumber(readOptionalTextInput(form, "viewpoint-rotation")) ?? 0,
1192
+ layers: {
1193
+ background: readCheckbox(form, "layer-background"),
1194
+ guides: readCheckbox(form, "layer-guides"),
1195
+ "orbits-back": readCheckbox(form, "layer-orbits-back"),
1196
+ "orbits-front": readCheckbox(form, "layer-orbits-front"),
1197
+ objects: readCheckbox(form, "layer-objects"),
1198
+ labels: readCheckbox(form, "layer-labels"),
1199
+ metadata: readCheckbox(form, "layer-metadata"),
1200
+ },
1201
+ filter: {
1202
+ query: readOptionalTextInput(form, "filter-query"),
1203
+ objectTypes: parseObjectTypes(readOptionalTextInput(form, "filter-object-types")),
1204
+ tags: splitTokens(readOptionalTextInput(form, "filter-tags")),
1205
+ groupIds: splitTokens(readOptionalTextInput(form, "filter-groups")),
1206
+ },
1207
+ };
1208
+ nextDocument.system.viewpoints = nextDocument.system.viewpoints
1209
+ .filter((viewpoint) => viewpoint.id !== current.id)
1210
+ .concat(replacement)
1211
+ .sort((left, right) => left.id.localeCompare(right.id));
1212
+ if (current.id !== replacement.id) {
1213
+ selection = { kind: "viewpoint", id: replacement.id };
1214
+ }
1215
+ return nextDocument;
1216
+ }
1217
+ function buildAnnotationDocumentFromInspector(currentId) {
1218
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1219
+ const form = inspector?.querySelector("form[data-editor-form='annotation']");
1220
+ const current = nextDocument.system?.annotations.find((annotation) => annotation.id === currentId);
1221
+ if (!form || !current || !nextDocument.system) {
1222
+ return nextDocument;
1223
+ }
1224
+ const nextId = readTextInput(form, "annotation-id") || current.id;
1225
+ const replacement = {
1226
+ ...current,
1227
+ id: nextId,
1228
+ label: readTextInput(form, "annotation-label") || current.label,
1229
+ targetObjectId: readOptionalTextInput(form, "annotation-target"),
1230
+ body: readOptionalTextInput(form, "annotation-body") ?? "",
1231
+ tags: splitTokens(readOptionalTextInput(form, "annotation-tags")),
1232
+ sourceObjectId: readOptionalTextInput(form, "annotation-source"),
1233
+ };
1234
+ nextDocument.system.annotations = nextDocument.system.annotations
1235
+ .filter((annotation) => annotation.id !== current.id)
1236
+ .concat(replacement)
1237
+ .sort((left, right) => left.id.localeCompare(right.id));
1238
+ if (current.id !== replacement.id) {
1239
+ selection = { kind: "annotation", id: replacement.id };
1240
+ }
1241
+ return nextDocument;
1242
+ }
1243
+ function buildObjectDocumentFromInspector(currentId) {
1244
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1245
+ const form = inspector?.querySelector("form[data-editor-form='object']");
1246
+ const current = nextDocument.objects.find((object) => object.id === currentId);
1247
+ if (!form || !current) {
1248
+ return nextDocument;
1249
+ }
1250
+ const nextId = readTextInput(form, "object-id") || current.id;
1251
+ const replacement = {
1252
+ ...current,
1253
+ id: nextId,
1254
+ type: readTextInput(form, "object-type") || current.type,
1255
+ properties: { ...current.properties },
1256
+ info: { ...current.info },
1257
+ placement: buildPlacementFromForm(form, current),
1258
+ };
1259
+ for (const field of OBJECT_STRING_FIELDS) {
1260
+ setOptionalProperty(replacement.properties, field, readOptionalTextInput(form, `prop-${field}`));
1261
+ }
1262
+ for (const field of OBJECT_UNIT_FIELDS) {
1263
+ setOptionalProperty(replacement.properties, field, parseOptionalNormalizedValue(readOptionalTextInput(form, `prop-${field}`)));
1264
+ }
1265
+ for (const field of OBJECT_NUMBER_FIELDS) {
1266
+ setOptionalProperty(replacement.properties, field, parseNullableNumber(readOptionalTextInput(form, `prop-${field}`)));
1267
+ }
1268
+ setOptionalProperty(replacement.properties, "tags", splitTokens(readOptionalTextInput(form, "prop-tags")));
1269
+ replacement.properties.hidden = readCheckbox(form, "prop-hidden");
1270
+ const description = readOptionalTextInput(form, "info-description");
1271
+ if (description) {
1272
+ replacement.info.description = description;
1273
+ }
1274
+ else {
1275
+ delete replacement.info.description;
1276
+ }
1277
+ const updatedDocument = replaceObject(nextDocument, current.id, replacement);
1278
+ if (current.id !== replacement.id) {
1279
+ selection = { kind: "object", id: replacement.id };
1280
+ }
1281
+ return updatedDocument;
1282
+ }
1283
+ function renderStatusBar() {
1284
+ const errorCount = diagnostics.filter((entry) => entry.diagnostic.severity === "error").length;
1285
+ const warningCount = diagnostics.filter((entry) => entry.diagnostic.severity === "warning").length;
1286
+ const selectionLabel = selection ? describePath(selection) : "Nothing selected";
1287
+ statusBar.innerHTML = `
1288
+ <span class="wo-editor-status-pill${dirty ? " is-dirty" : " is-clean"}">${dirty ? "Unsaved changes" : "Saved"}</span>
1289
+ <span class="wo-editor-status-pill">Schema ${escapeHtml(atlasDocument.version)}</span>
1290
+ <span class="wo-editor-status-pill${errorCount > 0 ? " is-error" : warningCount > 0 ? " is-warning" : ""}">${errorCount} errors · ${warningCount} warnings</span>
1291
+ <span class="wo-editor-status-pill">${escapeHtml(selectionLabel)}</span>
1292
+ `;
1293
+ }
1294
+ function updateLiveRegion() {
1295
+ const errorCount = diagnostics.filter((entry) => entry.diagnostic.severity === "error").length;
1296
+ const warningCount = diagnostics.filter((entry) => entry.diagnostic.severity === "warning").length;
1297
+ liveRegion.textContent = `${dirty ? "Unsaved changes." : "Saved."} ${errorCount} errors, ${warningCount} warnings. ${selection ? describePath(selection) : "Nothing selected"}.`;
1298
+ }
1299
+ function schedulePreviewRender() {
1300
+ if (previewTimer !== null) {
1301
+ return;
1302
+ }
1303
+ previewTimer = window.setTimeout(() => {
1304
+ previewTimer = null;
1305
+ renderPreviewNow();
1306
+ }, PREVIEW_BATCH_DELAY_MS);
1307
+ }
1308
+ function renderPreviewNow() {
1309
+ if (!viewer) {
1310
+ return;
1311
+ }
1312
+ const nextSvg = viewer.exportSvg();
1313
+ if (previewVisual && nextSvg !== lastPreviewSvg) {
1314
+ previewVisual.innerHTML = nextSvg;
1315
+ lastPreviewSvg = nextSvg;
1316
+ }
1317
+ const nextMarkup = buildEmbedMarkup(getCurrentSourceForExport(), atlasDocument);
1318
+ if (previewMarkup && nextMarkup !== lastPreviewMarkup) {
1319
+ previewMarkup.textContent = nextMarkup;
1320
+ lastPreviewMarkup = nextMarkup;
1321
+ }
1322
+ }
1323
+ function maybeFitOrbitResizeInView(objectId) {
1324
+ if (!viewer) {
1325
+ return;
1326
+ }
1327
+ const details = viewer.getObjectDetails(objectId);
1328
+ if (!details) {
1329
+ return;
1330
+ }
1331
+ const visibleBounds = getViewerVisibleBounds(viewer.getScene(), viewer.getState());
1332
+ const margin = 36 / Math.max(viewer.getState().scale, 0.001);
1333
+ const point = details.renderObject;
1334
+ if (point.x < visibleBounds.minX + margin ||
1335
+ point.x > visibleBounds.maxX - margin ||
1336
+ point.y < visibleBounds.minY + margin ||
1337
+ point.y > visibleBounds.maxY - margin) {
1338
+ viewer.fitToSystem();
1339
+ }
1340
+ }
1341
+ function shouldIgnoreShortcutEvent(event) {
1342
+ const target = event.target;
1343
+ if (!target) {
1344
+ return false;
1345
+ }
1346
+ const tagName = target.tagName.toLowerCase();
1347
+ return target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select";
1348
+ }
1349
+ function getSelectionDiagnostics(currentSelection, entries) {
1350
+ if (!currentSelection) {
1351
+ return [];
1352
+ }
1353
+ const currentKey = selectionKey(currentSelection);
1354
+ return entries.filter((entry) => {
1355
+ const entryKey = selectionKey(entry.path);
1356
+ if (entryKey && entryKey === currentKey) {
1357
+ return true;
1358
+ }
1359
+ return currentSelection.kind === "object" && entry.diagnostic.objectId === currentSelection.id;
1360
+ });
1361
+ }
1362
+ function getPlacementDiagnosticsForSelection(currentSelection, entries) {
1363
+ return getSelectionDiagnostics(currentSelection, entries).filter((entry) => {
1364
+ const field = entry.diagnostic.field ?? "";
1365
+ return (PLACEMENT_DIAGNOSTIC_FIELDS.has(field) ||
1366
+ entry.diagnostic.message.toLowerCase().includes("placement") ||
1367
+ entry.diagnostic.message.toLowerCase().includes("orbit") ||
1368
+ entry.diagnostic.message.toLowerCase().includes("surface") ||
1369
+ entry.diagnostic.message.toLowerCase().includes("lagrange") ||
1370
+ entry.diagnostic.message.toLowerCase().includes("anchor"));
1371
+ });
1372
+ }
1373
+ function renderInspectorDiagnosticSummary(currentSelection, entries) {
1374
+ const selectionDiagnostics = getSelectionDiagnostics(currentSelection, entries);
1375
+ if (selectionDiagnostics.length === 0) {
1376
+ return "";
1377
+ }
1378
+ return `<div class="wo-editor-inspector-summary">
1379
+ ${selectionDiagnostics
1380
+ .slice(0, 4)
1381
+ .map((entry) => `<article class="wo-editor-diagnostic wo-editor-diagnostic-${escapeHtml(entry.diagnostic.severity)}">
1382
+ <strong>${escapeHtml(entry.diagnostic.severity.toUpperCase())}</strong>
1383
+ <span>${escapeHtml(entry.diagnostic.field ?? describePath(currentSelection))}</span>
1384
+ <p>${escapeHtml(entry.diagnostic.message)}</p>
1385
+ </article>`)
1386
+ .join("")}
1387
+ </div>`;
1388
+ }
1389
+ function decorateInspectorDiagnostics(currentSelection, entries) {
1390
+ if (!inspector || !currentSelection) {
1391
+ return;
1392
+ }
1393
+ const selectionDiagnostics = getSelectionDiagnostics(currentSelection, entries);
1394
+ const fieldMap = new Map();
1395
+ for (const entry of selectionDiagnostics) {
1396
+ for (const inputName of mapDiagnosticFieldToInputNames(currentSelection, entry.diagnostic.field)) {
1397
+ const currentEntries = fieldMap.get(inputName) ?? [];
1398
+ currentEntries.push(entry);
1399
+ fieldMap.set(inputName, currentEntries);
1400
+ }
1401
+ }
1402
+ for (const [inputName, fieldDiagnostics] of fieldMap) {
1403
+ const input = inspector.querySelector(`[name="${CSS.escape(inputName)}"]`);
1404
+ const field = input?.closest(".wo-editor-field, .wo-editor-checkbox");
1405
+ if (!input || !field) {
1406
+ continue;
1407
+ }
1408
+ const hasError = fieldDiagnostics.some((entry) => entry.diagnostic.severity === "error");
1409
+ const hasWarning = fieldDiagnostics.some((entry) => entry.diagnostic.severity === "warning");
1410
+ field.classList.add(hasError ? "has-error" : "has-warning");
1411
+ input.setAttribute("aria-invalid", hasError ? "true" : "false");
1412
+ const note = document.createElement("div");
1413
+ note.className = `wo-editor-field-note${hasError ? " is-error" : hasWarning ? " is-warning" : ""}`;
1414
+ note.textContent = fieldDiagnostics[0]?.diagnostic.message ?? "";
1415
+ field.append(note);
1416
+ }
1417
+ }
1418
+ }
1419
+ function resolveInitialEditorState(options) {
1420
+ if (options.atlasDocument) {
1421
+ const atlasDocument = cloneAtlasDocument(options.atlasDocument);
1422
+ return {
1423
+ atlasDocument,
1424
+ source: formatDocument(atlasDocument, { schema: "2.0" }),
1425
+ diagnostics: collectDocumentDiagnostics(atlasDocument),
1426
+ };
1427
+ }
1428
+ if (options.source) {
1429
+ const loaded = loadWorldOrbitSourceWithDiagnostics(options.source);
1430
+ if (loaded.ok && loaded.value) {
1431
+ const atlasDocument = loaded.value.atlasDocument ?? upgradeDocumentToV2(loaded.value.document);
1432
+ return {
1433
+ atlasDocument,
1434
+ source: formatDocument(atlasDocument, { schema: "2.0" }),
1435
+ diagnostics: mergeDiagnostics(resolveAtlasDiagnostics(atlasDocument, loaded.diagnostics), collectDocumentDiagnostics(atlasDocument)),
1436
+ };
1437
+ }
1438
+ }
1439
+ const atlasDocument = createEmptyAtlasDocument("WorldOrbit");
1440
+ return {
1441
+ atlasDocument,
1442
+ source: formatDocument(atlasDocument, { schema: "2.0" }),
1443
+ diagnostics: collectDocumentDiagnostics(atlasDocument),
1444
+ };
1445
+ }
1446
+ function buildEditorMarkup() {
1447
+ const previewOpen = shouldPreviewSectionBeOpenByDefault();
1448
+ return `<section class="wo-editor-shell">
1449
+ <div class="wo-editor-toolbar" data-editor-toolbar></div>
1450
+ <div class="wo-editor-status" data-editor-status role="status" aria-live="polite"></div>
1451
+ <div class="wo-editor-main">
1452
+ <aside class="wo-editor-sidebar" data-editor-pane="sidebar">
1453
+ <div class="wo-editor-panel">${renderPanelSection("Atlas", "atlas", `<div class="wo-editor-outline" data-editor-outline></div>`)}</div>
1454
+ <div class="wo-editor-panel">${renderPanelSection("Diagnostics", "diagnostics", `<div class="wo-editor-diagnostics" data-editor-diagnostics></div>`)}</div>
1455
+ </aside>
1456
+ <div class="wo-editor-stage-panel">
1457
+ <div class="wo-editor-stage-shell" data-editor-stage-shell>
1458
+ <div class="wo-editor-stage" data-editor-stage></div>
1459
+ <div class="wo-editor-overlay" data-editor-overlay></div>
1460
+ </div>
1461
+ <div class="wo-editor-panel" data-editor-pane="preview">
1462
+ ${renderPanelSection("Preview", "preview", `<div class="wo-editor-preview">
1463
+ <div class="wo-editor-preview-card">
1464
+ <h3>Static SVG</h3>
1465
+ <div class="wo-editor-preview-visual" data-editor-preview-visual></div>
1466
+ </div>
1467
+ <div class="wo-editor-preview-card">
1468
+ <h3>Embed Markup</h3>
1469
+ <pre class="wo-editor-preview-markup" data-editor-preview-markup></pre>
1470
+ </div>
1471
+ </div>`, previewOpen)}
1472
+ </div>
1473
+ </div>
1474
+ <aside class="wo-editor-panel wo-editor-inspector" data-editor-pane="inspector">
1475
+ ${renderPanelSection("Inspector", "inspector", `<div data-editor-inspector></div>`)}
1476
+ </aside>
1477
+ </div>
1478
+ <div class="wo-editor-panel wo-editor-text-panel" data-editor-pane="text">
1479
+ ${renderPanelSection("Source", "source", `<textarea
1480
+ class="wo-editor-source"
1481
+ data-editor-source
1482
+ spellcheck="false"
1483
+ aria-describedby="worldorbit-editor-source-diagnostics"
1484
+ ></textarea>
1485
+ <div
1486
+ class="wo-editor-source-diagnostics"
1487
+ id="worldorbit-editor-source-diagnostics"
1488
+ data-editor-source-diagnostics
1489
+ aria-live="polite"
1490
+ ></div>`)}
1491
+ </div>
1492
+ <div class="wo-editor-live-region" data-editor-live aria-live="polite" aria-atomic="true"></div>
1493
+ </section>`;
1494
+ }
1495
+ function renderPanelSection(title, sectionId, content, open = true) {
1496
+ return `<details class="wo-editor-panel-section" data-editor-panel-section="${escapeHtml(sectionId)}"${open ? " open" : ""}>
1497
+ <summary><span>${escapeHtml(title)}</span></summary>
1498
+ <div class="wo-editor-panel-section-body">${content}</div>
1499
+ </details>`;
1500
+ }
1501
+ function renderInspectorSection(formId, sectionId, title, content, open = false) {
1502
+ const stateKey = inspectorSectionStateKey(formId, sectionId);
1503
+ return `<details class="wo-editor-inspector-section" data-editor-form-section="${escapeHtml(sectionId)}" data-editor-form-id="${escapeHtml(formId)}" data-editor-section-state="${escapeHtml(stateKey)}"${open ? " open" : ""}>
1504
+ <summary><span>${escapeHtml(title)}</span></summary>
1505
+ <div class="wo-editor-inspector-section-body">${content}</div>
1506
+ </details>`;
1507
+ }
1508
+ function renderFieldLabel(label, name) {
1509
+ return `<span class="wo-editor-field-label">${escapeHtml(label)}${renderFieldHelp(name)}</span>`;
1510
+ }
1511
+ function renderFieldHelp(name) {
1512
+ const help = FIELD_HELP[name];
1513
+ if (!help) {
1514
+ return "";
1515
+ }
1516
+ return `<details class="wo-editor-field-help">
1517
+ <summary aria-label="Explain ${escapeAttribute(name)}">?</summary>
1518
+ <div class="wo-editor-field-help-card">
1519
+ <p>${escapeHtml(help.description)}</p>
1520
+ ${(help.references?.length ?? 0) > 0
1521
+ ? `<div class="wo-editor-field-help-chips">${(help.references ?? [])
1522
+ .map((entry) => `<span>${escapeHtml(entry)}</span>`)
1523
+ .join("")}</div>`
1524
+ : ""}
1525
+ </div>
1526
+ </details>`;
1527
+ }
1528
+ function captureInspectorSectionState(container, state) {
1529
+ for (const section of container.querySelectorAll("[data-editor-section-state]")) {
1530
+ const key = section.dataset.editorSectionState;
1531
+ if (!key) {
1532
+ continue;
1533
+ }
1534
+ state.set(key, section.open);
1535
+ }
1536
+ }
1537
+ function applyInspectorSectionState(container, state) {
1538
+ for (const section of container.querySelectorAll("[data-editor-section-state]")) {
1539
+ const key = section.dataset.editorSectionState;
1540
+ if (!key || !state.has(key)) {
1541
+ continue;
1542
+ }
1543
+ section.open = state.get(key) === true;
1544
+ }
1545
+ }
1546
+ function inspectorSectionStateKey(formId, sectionId) {
1547
+ return `${formId}:${sectionId}`;
1548
+ }
1549
+ function shouldPreviewSectionBeOpenByDefault() {
1550
+ if (typeof window === "undefined") {
1551
+ return true;
1552
+ }
1553
+ if (typeof window.matchMedia === "function") {
1554
+ return !window.matchMedia("(max-width: 1280px)").matches;
1555
+ }
1556
+ return window.innerWidth > 1280;
1557
+ }
1558
+ function renderOutlineButton(path, label, activeKey, diagnosticBuckets) {
1559
+ const key = selectionKey(path);
1560
+ const bucket = key ? diagnosticBuckets.get(key) : null;
1561
+ const badge = bucket && (bucket.errors > 0 || bucket.warnings > 0)
1562
+ ? `<span class="wo-editor-outline-badge${bucket.errors > 0 ? " is-error" : " is-warning"}">${bucket.errors > 0 ? bucket.errors : bucket.warnings}</span>`
1563
+ : "";
1564
+ return `<button type="button" class="wo-editor-outline-item${key === activeKey ? " is-active" : ""}" data-path-kind="${escapeHtml(path.kind)}"${path.id ? ` data-path-id="${escapeHtml(path.id)}"` : ""}${path.key ? ` data-path-key="${escapeHtml(path.key)}"` : ""}><span>${escapeHtml(label)}</span>${badge}</button>`;
1565
+ }
1566
+ function renderSystemInspector(formState) {
1567
+ return `<form class="wo-editor-form" data-editor-form="system">
1568
+ <h2>System</h2>
1569
+ ${renderInspectorSection("system", "basics", "Basics", `${renderTextField("System ID", "system-id", formState.system?.id ?? "")}
1570
+ ${renderTextField("Title", "system-title", formState.system?.title ?? "")}`, true)}
1571
+ </form>`;
1572
+ }
1573
+ function renderDefaultsInspector(formState) {
1574
+ const defaults = formState.system?.defaults;
1575
+ return `<form class="wo-editor-form" data-editor-form="defaults">
1576
+ <h2>Defaults</h2>
1577
+ ${renderInspectorSection("defaults", "basics", "Basics", `${renderSelectField("Projection", "defaults-view", [
1578
+ ["topdown", "Topdown"],
1579
+ ["isometric", "Isometric"],
1580
+ ], defaults?.view ?? "topdown")}
1581
+ ${renderTextField("Scale preset", "defaults-scale", defaults?.scale ?? "")}
1582
+ ${renderTextField("Units", "defaults-units", defaults?.units ?? "")}
1583
+ ${renderSelectField("Render preset", "defaults-preset", [
1584
+ ["", "Document default"],
1585
+ ["diagram", "Diagram"],
1586
+ ["presentation", "Presentation"],
1587
+ ["atlas-card", "Atlas Card"],
1588
+ ["markdown", "Markdown"],
1589
+ ], defaults?.preset ?? "")}
1590
+ ${renderTextField("Theme", "defaults-theme", defaults?.theme ?? "")}`, true)}
1591
+ </form>`;
1592
+ }
1593
+ function renderMetadataInspector(formState, key) {
1594
+ const value = formState.system?.atlasMetadata[key] ?? "";
1595
+ return `<form class="wo-editor-form" data-editor-form="metadata">
1596
+ <h2>Metadata</h2>
1597
+ ${renderInspectorSection("metadata", "entry", "Entry", `${renderTextField("Key", "metadata-key", key)}
1598
+ ${renderTextAreaField("Value", "metadata-value", value)}`, true)}
1599
+ </form>`;
1600
+ }
1601
+ function renderViewpointInspector(formState, id) {
1602
+ const viewpoint = formState.viewpoints.find((entry) => entry.id === id);
1603
+ if (!viewpoint) {
1604
+ return `<p class="wo-editor-empty">Viewpoint not found.</p>`;
1605
+ }
1606
+ return `<form class="wo-editor-form" data-editor-form="viewpoint">
1607
+ <h2>Viewpoint</h2>
1608
+ ${renderInspectorSection("viewpoint", "basics", "Basics", `${renderTextField("ID", "viewpoint-id", viewpoint.id)}
1609
+ ${renderTextField("Label", "viewpoint-label", viewpoint.label)}
1610
+ ${renderTextAreaField("Summary", "viewpoint-summary", viewpoint.summary)}
1611
+ ${renderTextField("Focus object", "viewpoint-focus", viewpoint.focusObjectId ?? "")}
1612
+ ${renderTextField("Selected object", "viewpoint-select", viewpoint.selectedObjectId ?? "")}
1613
+ ${renderSelectField("Projection", "viewpoint-projection", [
1614
+ ["topdown", "Topdown"],
1615
+ ["isometric", "Isometric"],
1616
+ ], viewpoint.projection)}
1617
+ ${renderSelectField("Preset", "viewpoint-preset", [
1618
+ ["", "Document default"],
1619
+ ["diagram", "Diagram"],
1620
+ ["presentation", "Presentation"],
1621
+ ["atlas-card", "Atlas Card"],
1622
+ ["markdown", "Markdown"],
1623
+ ], viewpoint.preset ?? "")}
1624
+ ${renderTextField("Zoom", "viewpoint-zoom", viewpoint.zoom === null ? "" : String(viewpoint.zoom))}
1625
+ ${renderTextField("Rotation", "viewpoint-rotation", String(viewpoint.rotationDeg))}`, true)}
1626
+ ${renderInspectorSection("viewpoint", "layers", "Layers", `<fieldset class="wo-editor-fieldset">
1627
+ <legend>Layers</legend>
1628
+ ${renderCheckboxField("Background", "layer-background", viewpoint.layers.background !== false)}
1629
+ ${renderCheckboxField("Guides", "layer-guides", viewpoint.layers.guides !== false)}
1630
+ ${renderCheckboxField("Orbits back", "layer-orbits-back", viewpoint.layers["orbits-back"] !== false)}
1631
+ ${renderCheckboxField("Orbits front", "layer-orbits-front", viewpoint.layers["orbits-front"] !== false)}
1632
+ ${renderCheckboxField("Objects", "layer-objects", viewpoint.layers.objects !== false)}
1633
+ ${renderCheckboxField("Labels", "layer-labels", viewpoint.layers.labels !== false)}
1634
+ ${renderCheckboxField("Metadata", "layer-metadata", viewpoint.layers.metadata !== false)}
1635
+ </fieldset>`)}
1636
+ ${renderInspectorSection("viewpoint", "filter", "Filter", `${renderTextField("Filter query", "filter-query", viewpoint.filter?.query ?? "")}
1637
+ ${renderTextField("Filter object types", "filter-object-types", viewpoint.filter?.objectTypes.join(" ") ?? "")}
1638
+ ${renderTextField("Filter tags", "filter-tags", viewpoint.filter?.tags.join(" ") ?? "")}
1639
+ ${renderTextField("Filter groups", "filter-groups", viewpoint.filter?.groupIds.join(" ") ?? "")}`)}
1640
+ </form>`;
1641
+ }
1642
+ function renderAnnotationInspector(formState, id) {
1643
+ const annotation = formState.system?.annotations.find((entry) => entry.id === id);
1644
+ if (!annotation) {
1645
+ return `<p class="wo-editor-empty">Annotation not found.</p>`;
1646
+ }
1647
+ return `<form class="wo-editor-form" data-editor-form="annotation">
1648
+ <h2>Annotation</h2>
1649
+ ${renderInspectorSection("annotation", "entry", "Entry", `${renderTextField("ID", "annotation-id", annotation.id)}
1650
+ ${renderTextField("Label", "annotation-label", annotation.label)}
1651
+ ${renderTextField("Target object", "annotation-target", annotation.targetObjectId ?? "")}
1652
+ ${renderTextField("Source object", "annotation-source", annotation.sourceObjectId ?? "")}
1653
+ ${renderTextAreaField("Body", "annotation-body", annotation.body)}
1654
+ ${renderTextField("Tags", "annotation-tags", annotation.tags.join(" "))}`, true)}
1655
+ </form>`;
1656
+ }
1657
+ function renderObjectInspector(formState, id) {
1658
+ const object = formState.objects.find((entry) => entry.id === id);
1659
+ if (!object) {
1660
+ return `<p class="wo-editor-empty">Object not found.</p>`;
1661
+ }
1662
+ const placementMode = object.placement?.mode ?? "";
1663
+ const placementTarget = object.placement?.mode === "orbit" || object.placement?.mode === "surface" || object.placement?.mode === "at"
1664
+ ? object.placement.target
1665
+ : "";
1666
+ const freeValue = object.placement?.mode === "free"
1667
+ ? object.placement.distance
1668
+ ? formatUnitValue(object.placement.distance)
1669
+ : object.placement.descriptor ?? ""
1670
+ : "";
1671
+ return `<form class="wo-editor-form" data-editor-form="object">
1672
+ <h2>Object</h2>
1673
+ ${renderInspectorSection("object", "identity", "Identity", `${renderTextField("ID", "object-id", object.id)}
1674
+ ${renderSelectField("Type", "object-type", OBJECT_TYPES.map((type) => [type, humanizeIdentifier(type)]), object.type)}`, true)}
1675
+ ${renderInspectorSection("object", "placement", "Placement", `${renderSelectField("Placement mode", "placement-mode", [
1676
+ ["", "None"],
1677
+ ["orbit", "Orbit"],
1678
+ ["at", "At"],
1679
+ ["surface", "Surface"],
1680
+ ["free", "Free"],
1681
+ ], placementMode)}
1682
+ ${renderTextField("Placement target", "placement-target", placementTarget)}
1683
+ ${renderTextField("Free value", "placement-free", freeValue)}
1684
+ ${renderTextField("Distance", "placement-distance", object.placement?.mode === "orbit" && object.placement.distance ? formatUnitValue(object.placement.distance) : "")}
1685
+ ${renderTextField("Semi-major", "placement-semiMajor", object.placement?.mode === "orbit" && object.placement.semiMajor ? formatUnitValue(object.placement.semiMajor) : "")}
1686
+ ${renderTextField("Eccentricity", "placement-eccentricity", object.placement?.mode === "orbit" && object.placement.eccentricity !== undefined ? String(object.placement.eccentricity) : "")}
1687
+ ${renderTextField("Period", "placement-period", object.placement?.mode === "orbit" && object.placement.period ? formatUnitValue(object.placement.period) : "")}
1688
+ ${renderTextField("Angle", "placement-angle", object.placement?.mode === "orbit" && object.placement.angle ? formatUnitValue(object.placement.angle) : "")}
1689
+ ${renderTextField("Inclination", "placement-inclination", object.placement?.mode === "orbit" && object.placement.inclination ? formatUnitValue(object.placement.inclination) : "")}
1690
+ ${renderTextField("Phase", "placement-phase", object.placement?.mode === "orbit" && object.placement.phase ? formatUnitValue(object.placement.phase) : "")}`, true)}
1691
+ ${renderInspectorSection("object", "properties", "Properties", `<fieldset class="wo-editor-fieldset">
1692
+ <legend>Properties</legend>
1693
+ ${renderTextField("Kind", "prop-kind", readStringProperty(object.properties.kind))}
1694
+ ${renderTextField("Class", "prop-class", readStringProperty(object.properties.class))}
1695
+ ${renderTextField("Culture", "prop-culture", readStringProperty(object.properties.culture))}
1696
+ ${renderTextField("Tags", "prop-tags", readTagsProperty(object.properties.tags))}
1697
+ ${renderTextField("Color", "prop-color", readStringProperty(object.properties.color))}
1698
+ ${renderTextField("Image", "prop-image", readStringProperty(object.properties.image))}
1699
+ ${renderCheckboxField("Hidden", "prop-hidden", object.properties.hidden === true)}
1700
+ ${renderTextField("Radius", "prop-radius", readUnitProperty(object.properties.radius))}
1701
+ ${renderTextField("Mass", "prop-mass", readUnitProperty(object.properties.mass))}
1702
+ ${renderTextField("Density", "prop-density", readUnitProperty(object.properties.density))}
1703
+ ${renderTextField("Gravity", "prop-gravity", readUnitProperty(object.properties.gravity))}
1704
+ ${renderTextField("Temperature", "prop-temperature", readUnitProperty(object.properties.temperature))}
1705
+ ${renderTextField("Albedo", "prop-albedo", readNumberProperty(object.properties.albedo))}
1706
+ ${renderTextField("Atmosphere", "prop-atmosphere", readStringProperty(object.properties.atmosphere))}
1707
+ ${renderTextField("Inner", "prop-inner", readUnitProperty(object.properties.inner))}
1708
+ ${renderTextField("Outer", "prop-outer", readUnitProperty(object.properties.outer))}
1709
+ ${renderTextField("On", "prop-on", readStringProperty(object.properties.on))}
1710
+ ${renderTextField("Source", "prop-source", readStringProperty(object.properties.source))}
1711
+ ${renderTextField("Cycle", "prop-cycle", readUnitProperty(object.properties.cycle))}
1712
+ </fieldset>`)}
1713
+ ${renderInspectorSection("object", "info", "Info", `${renderTextAreaField("Description", "info-description", object.info.description ?? "")}`)}
1714
+ </form>`;
1715
+ }
1716
+ function renderTextField(label, name, value) {
1717
+ return `<label class="wo-editor-field">${renderFieldLabel(label, name)}<input name="${escapeHtml(name)}" value="${escapeAttribute(value)}" /></label>`;
1718
+ }
1719
+ function renderTextAreaField(label, name, value) {
1720
+ return `<label class="wo-editor-field">${renderFieldLabel(label, name)}<textarea name="${escapeHtml(name)}">${escapeHtml(value)}</textarea></label>`;
1721
+ }
1722
+ function renderSelectField(label, name, options, selectedValue) {
1723
+ return `<label class="wo-editor-field">${renderFieldLabel(label, name)}<select name="${escapeHtml(name)}">${options
1724
+ .map(([value, optionLabel]) => `<option value="${escapeHtml(value)}"${value === selectedValue ? " selected" : ""}>${escapeHtml(optionLabel)}</option>`)
1725
+ .join("")}</select></label>`;
1726
+ }
1727
+ function renderCheckboxField(label, name, checked) {
1728
+ return `<label class="wo-editor-checkbox"><input type="checkbox" name="${escapeHtml(name)}"${checked ? " checked" : ""} />${renderFieldLabel(label, name)}</label>`;
1729
+ }
1730
+ function createHandleElement(kind, objectId, point, label) {
1731
+ const element = document.createElement("button");
1732
+ element.type = "button";
1733
+ element.className = "wo-editor-handle";
1734
+ element.dataset.handleKind = kind;
1735
+ element.dataset.objectId = objectId;
1736
+ element.style.left = `${point.x}px`;
1737
+ element.style.top = `${point.y}px`;
1738
+ element.textContent = label;
1739
+ return element;
1740
+ }
1741
+ function readTextInput(form, name) {
1742
+ return form.elements.namedItem(name)?.value.trim() ?? "";
1743
+ }
1744
+ function readOptionalTextInput(form, name) {
1745
+ const value = readTextInput(form, name);
1746
+ return value ? value : null;
1747
+ }
1748
+ function readCheckbox(form, name) {
1749
+ return form.elements.namedItem(name)?.checked ?? false;
1750
+ }
1751
+ function buildPlacementFromForm(form, current) {
1752
+ const mode = readTextInput(form, "placement-mode");
1753
+ const target = readOptionalTextInput(form, "placement-target");
1754
+ switch (mode) {
1755
+ case "orbit":
1756
+ return {
1757
+ mode,
1758
+ target: target ??
1759
+ (current.placement?.mode === "orbit"
1760
+ ? current.placement.target
1761
+ : current.id),
1762
+ distance: parseOptionalUnit(readOptionalTextInput(form, "placement-distance")),
1763
+ semiMajor: parseOptionalUnit(readOptionalTextInput(form, "placement-semiMajor")),
1764
+ eccentricity: parseNullableNumber(readOptionalTextInput(form, "placement-eccentricity")) ?? undefined,
1765
+ period: parseOptionalUnit(readOptionalTextInput(form, "placement-period")),
1766
+ angle: parseOptionalUnit(readOptionalTextInput(form, "placement-angle")),
1767
+ inclination: parseOptionalUnit(readOptionalTextInput(form, "placement-inclination")),
1768
+ phase: parseOptionalUnit(readOptionalTextInput(form, "placement-phase")),
1769
+ };
1770
+ case "at":
1771
+ return {
1772
+ mode,
1773
+ target: target ?? current.id,
1774
+ reference: parseAtReferenceString(target ?? current.id),
1775
+ };
1776
+ case "surface":
1777
+ return {
1778
+ mode,
1779
+ target: target ?? current.id,
1780
+ };
1781
+ case "free": {
1782
+ const freeValue = readOptionalTextInput(form, "placement-free");
1783
+ const distance = parseOptionalUnit(freeValue);
1784
+ return {
1785
+ mode,
1786
+ distance: distance ?? undefined,
1787
+ descriptor: distance ? undefined : freeValue ?? undefined,
1788
+ };
1789
+ }
1790
+ default:
1791
+ return null;
1792
+ }
1793
+ }
1794
+ function setOptionalProperty(properties, key, value) {
1795
+ const emptyArray = Array.isArray(value) && value.length === 0;
1796
+ if (value === null || value === undefined || emptyArray || value === "") {
1797
+ delete properties[key];
1798
+ return;
1799
+ }
1800
+ properties[key] = value;
1801
+ }
1802
+ function parseOptionalNormalizedValue(value) {
1803
+ if (!value) {
1804
+ return null;
1805
+ }
1806
+ const unit = parseOptionalUnit(value);
1807
+ return unit ?? value;
1808
+ }
1809
+ function parseOptionalUnit(value) {
1810
+ if (!value) {
1811
+ return undefined;
1812
+ }
1813
+ const match = value.match(/^(-?\d+(?:\.\d+)?)(au|km|re|sol|me|d|y|h|deg)?$/);
1814
+ if (!match) {
1815
+ return undefined;
1816
+ }
1817
+ return {
1818
+ value: Number(match[1]),
1819
+ unit: match[2] ?? null,
1820
+ };
1821
+ }
1822
+ function parseNullableNumber(value) {
1823
+ if (!value) {
1824
+ return null;
1825
+ }
1826
+ const parsed = Number(value);
1827
+ return Number.isFinite(parsed) ? parsed : null;
1828
+ }
1829
+ function parseObjectTypes(value) {
1830
+ const tokens = splitTokens(value);
1831
+ return tokens.filter((token) => OBJECT_TYPES.includes(token));
1832
+ }
1833
+ function splitTokens(value) {
1834
+ return value
1835
+ ?.split(/[\s,]+/)
1836
+ .map((entry) => entry.trim())
1837
+ .filter(Boolean) ?? [];
1838
+ }
1839
+ function createNewObject(type, id, document) {
1840
+ const orbitTarget = document.objects.find((object) => object.type === "star")?.id ??
1841
+ document.objects[0]?.id ??
1842
+ id;
1843
+ return {
1844
+ type,
1845
+ id,
1846
+ properties: {},
1847
+ placement: type === "structure" || type === "phenomenon"
1848
+ ? {
1849
+ mode: "at",
1850
+ target: `${orbitTarget}:L4`,
1851
+ reference: parseAtReferenceString(`${orbitTarget}:L4`),
1852
+ }
1853
+ : {
1854
+ mode: "orbit",
1855
+ target: orbitTarget,
1856
+ distance: { value: 1, unit: "au" },
1857
+ },
1858
+ info: {},
1859
+ };
1860
+ }
1861
+ function insertObject(document, object) {
1862
+ const next = cloneAtlasDocument(document);
1863
+ next.objects = next.objects
1864
+ .filter((entry) => entry.id !== object.id)
1865
+ .concat(object)
1866
+ .sort(compareObjects);
1867
+ return next;
1868
+ }
1869
+ function replaceObject(document, currentId, object) {
1870
+ const next = cloneAtlasDocument(document);
1871
+ next.objects = next.objects
1872
+ .filter((entry) => entry.id !== currentId)
1873
+ .concat(object)
1874
+ .sort(compareObjects);
1875
+ if (currentId !== object.id) {
1876
+ renameObjectReferences(next, currentId, object.id);
1877
+ }
1878
+ return next;
1879
+ }
1880
+ function renameObjectReferences(document, fromId, toId) {
1881
+ for (const object of document.objects) {
1882
+ if (object.id === toId) {
1883
+ continue;
1884
+ }
1885
+ if (object.placement?.mode === "orbit" && object.placement.target === fromId) {
1886
+ object.placement.target = toId;
1887
+ }
1888
+ if (object.placement?.mode === "surface" && object.placement.target === fromId) {
1889
+ object.placement.target = toId;
1890
+ }
1891
+ if (object.placement?.mode === "at") {
1892
+ const reference = object.placement.reference;
1893
+ if (reference.kind === "anchor" && reference.objectId === fromId) {
1894
+ reference.objectId = toId;
1895
+ }
1896
+ if (reference.kind === "lagrange") {
1897
+ if (reference.primary === fromId) {
1898
+ reference.primary = toId;
1899
+ }
1900
+ if (reference.secondary === fromId) {
1901
+ reference.secondary = toId;
1902
+ }
1903
+ }
1904
+ object.placement.target = formatAtReference(reference);
1905
+ }
1906
+ }
1907
+ for (const viewpoint of document.system?.viewpoints ?? []) {
1908
+ if (viewpoint.focusObjectId === fromId) {
1909
+ viewpoint.focusObjectId = toId;
1910
+ }
1911
+ if (viewpoint.selectedObjectId === fromId) {
1912
+ viewpoint.selectedObjectId = toId;
1913
+ }
1914
+ }
1915
+ for (const annotation of document.system?.annotations ?? []) {
1916
+ if (annotation.targetObjectId === fromId) {
1917
+ annotation.targetObjectId = toId;
1918
+ }
1919
+ if (annotation.sourceObjectId === fromId) {
1920
+ annotation.sourceObjectId = toId;
1921
+ }
1922
+ }
1923
+ }
1924
+ function removeSelectedNode(document, selection) {
1925
+ const next = removeAtlasDocumentNode(document, selection);
1926
+ if (selection.kind !== "object" || !selection.id) {
1927
+ return next;
1928
+ }
1929
+ for (const object of next.objects) {
1930
+ if (object.placement?.mode === "orbit" && object.placement.target === selection.id) {
1931
+ object.placement = null;
1932
+ }
1933
+ if (object.placement?.mode === "surface" && object.placement.target === selection.id) {
1934
+ object.placement = null;
1935
+ }
1936
+ if (object.placement?.mode === "at") {
1937
+ const reference = object.placement.reference;
1938
+ const touchesSelection = (reference.kind === "anchor" && reference.objectId === selection.id) ||
1939
+ (reference.kind === "lagrange" &&
1940
+ (reference.primary === selection.id || reference.secondary === selection.id));
1941
+ if (touchesSelection) {
1942
+ object.placement = null;
1943
+ }
1944
+ }
1945
+ }
1946
+ for (const viewpoint of next.system?.viewpoints ?? []) {
1947
+ if (viewpoint.focusObjectId === selection.id) {
1948
+ viewpoint.focusObjectId = null;
1949
+ }
1950
+ if (viewpoint.selectedObjectId === selection.id) {
1951
+ viewpoint.selectedObjectId = null;
1952
+ }
1953
+ }
1954
+ for (const annotation of next.system?.annotations ?? []) {
1955
+ if (annotation.targetObjectId === selection.id) {
1956
+ annotation.targetObjectId = null;
1957
+ }
1958
+ if (annotation.sourceObjectId === selection.id) {
1959
+ annotation.sourceObjectId = null;
1960
+ }
1961
+ }
1962
+ return next;
1963
+ }
1964
+ function updateOrbitPhase(document, objectId, details, pointer) {
1965
+ const orbit = details.orbit;
1966
+ if (!orbit || details.object.placement?.mode !== "orbit") {
1967
+ return document;
1968
+ }
1969
+ const unrotated = rotatePoint(pointer, { x: orbit.cx, y: orbit.cy }, -orbit.rotationDeg);
1970
+ const rx = orbit.kind === "circle" ? orbit.radius ?? 1 : orbit.rx ?? 1;
1971
+ const ry = orbit.kind === "circle" ? orbit.radius ?? 1 : orbit.ry ?? 1;
1972
+ const radians = Math.atan2((unrotated.y - orbit.cy) / Math.max(ry, 1), (unrotated.x - orbit.cx) / Math.max(rx, 1));
1973
+ const phaseDeg = normalizeDegrees((radians * 180) / Math.PI);
1974
+ const next = cloneAtlasDocument(document);
1975
+ const object = next.objects.find((entry) => entry.id === objectId);
1976
+ if (!object || object.placement?.mode !== "orbit") {
1977
+ return document;
1978
+ }
1979
+ object.placement.phase = {
1980
+ value: roundNumber(phaseDeg, 2),
1981
+ unit: "deg",
1982
+ };
1983
+ return next;
1984
+ }
1985
+ function updateOrbitRadius(document, objectId, details, pointer, dragContext) {
1986
+ const orbit = details.orbit;
1987
+ if (!orbit || details.object.placement?.mode !== "orbit" || !dragContext) {
1988
+ return document;
1989
+ }
1990
+ const unrotated = rotatePoint(pointer, { x: orbit.cx, y: orbit.cy }, -orbit.rotationDeg);
1991
+ const nextDisplayedRadius = Math.max(Math.abs(unrotated.x - orbit.cx), 24);
1992
+ const nextBaseRadius = Math.max(nextDisplayedRadius - dragContext.radiusOffsetPx, dragContext.innerPx);
1993
+ const nextMetric = orbitRadiusPxToMetric(nextBaseRadius, dragContext.innerPx, dragContext.stepPx);
1994
+ const next = cloneAtlasDocument(document);
1995
+ const object = next.objects.find((entry) => entry.id === objectId);
1996
+ if (!object || object.placement?.mode !== "orbit") {
1997
+ return document;
1998
+ }
1999
+ const currentValue = object.placement.semiMajor ??
2000
+ object.placement.distance ?? {
2001
+ value: 1,
2002
+ unit: "au",
2003
+ };
2004
+ const scaled = distanceMetricToUnitValue(Math.max(nextMetric, 0), dragContext.preferredUnit ?? currentValue.unit);
2005
+ if (object.placement.semiMajor) {
2006
+ object.placement.semiMajor = scaled;
2007
+ }
2008
+ else {
2009
+ object.placement.distance = scaled;
2010
+ }
2011
+ return next;
2012
+ }
2013
+ function updateAtReference(document, objectId, scene, pointer) {
2014
+ const candidate = findNearestAtCandidate(scene, objectId, pointer);
2015
+ if (!candidate) {
2016
+ return document;
2017
+ }
2018
+ const next = cloneAtlasDocument(document);
2019
+ const object = next.objects.find((entry) => entry.id === objectId);
2020
+ if (!object || object.placement?.mode !== "at") {
2021
+ return document;
2022
+ }
2023
+ object.placement.reference = candidate.reference;
2024
+ object.placement.target = formatAtReference(candidate.reference);
2025
+ return next;
2026
+ }
2027
+ function updateSurfaceTarget(document, objectId, scene, pointer) {
2028
+ const target = findNearestSceneObject(scene, objectId, pointer, (entry) => SURFACE_TARGET_TYPES.has(entry.object.type));
2029
+ if (!target) {
2030
+ return document;
2031
+ }
2032
+ const next = cloneAtlasDocument(document);
2033
+ const object = next.objects.find((entry) => entry.id === objectId);
2034
+ if (!object || object.placement?.mode !== "surface") {
2035
+ return document;
2036
+ }
2037
+ object.placement.target = target.objectId;
2038
+ return next;
2039
+ }
2040
+ function createOrbitRadiusDragContext(document, scene, details) {
2041
+ if (details.object.placement?.mode !== "orbit" || !details.orbit || !details.parent) {
2042
+ return null;
2043
+ }
2044
+ const targetId = details.object.placement.target;
2045
+ const siblingCount = document.objects.filter((entry) => entry.placement?.mode === "orbit" &&
2046
+ entry.placement.target === targetId).length;
2047
+ const spacingFactor = layoutPresetSpacingForScene(scene.layoutPreset);
2048
+ const stepPx = (siblingCount > 2 ? 54 : 64) * spacingFactor * scene.scaleModel.orbitDistanceMultiplier;
2049
+ const innerPx = details.parent.radius +
2050
+ 56 * spacingFactor * scene.scaleModel.orbitDistanceMultiplier;
2051
+ const currentValue = details.object.placement.semiMajor ?? details.object.placement.distance ?? null;
2052
+ const currentMetric = unitValueToDistanceMetric(currentValue);
2053
+ const displayedRadius = details.orbit.kind === "circle" ? details.orbit.radius ?? 1 : details.orbit.rx ?? 1;
2054
+ const baseRadius = orbitMetricToRadiusPx(currentMetric ?? 0, innerPx, stepPx);
2055
+ return {
2056
+ innerPx,
2057
+ stepPx,
2058
+ radiusOffsetPx: displayedRadius - baseRadius,
2059
+ preferredUnit: currentValue?.unit ?? null,
2060
+ };
2061
+ }
2062
+ function updateFreeDistance(document, objectId, scene, details, pointer) {
2063
+ if (details.object.placement?.mode !== "free") {
2064
+ return document;
2065
+ }
2066
+ const railX = scene.width - scene.padding - 140;
2067
+ const offsetPx = Math.max(0, railX - pointer.x);
2068
+ const next = cloneAtlasDocument(document);
2069
+ const object = next.objects.find((entry) => entry.id === objectId);
2070
+ if (!object || object.placement?.mode !== "free") {
2071
+ return document;
2072
+ }
2073
+ const preferredUnit = normalizeFreeDistanceUnit(object.placement.distance?.unit ?? null);
2074
+ const metric = offsetPx / Math.max(FREE_DISTANCE_PIXEL_FACTOR * scene.scaleModel.freePlacementMultiplier, 1);
2075
+ if (metric < 0.01) {
2076
+ object.placement.distance = undefined;
2077
+ if (!object.placement.descriptor) {
2078
+ delete object.placement.descriptor;
2079
+ }
2080
+ return next;
2081
+ }
2082
+ object.placement.distance = distanceMetricToUnitValue(metric, preferredUnit);
2083
+ delete object.placement.descriptor;
2084
+ return next;
2085
+ }
2086
+ function findNearestSceneObject(scene, selectedObjectId, pointer, predicate = () => true) {
2087
+ let nearest = null;
2088
+ let nearestDistance = Number.POSITIVE_INFINITY;
2089
+ for (const entry of scene.objects) {
2090
+ if (entry.hidden || entry.objectId === selectedObjectId || !predicate(entry)) {
2091
+ continue;
2092
+ }
2093
+ const distance = Math.hypot(pointer.x - entry.x, pointer.y - entry.y);
2094
+ if (distance < nearestDistance) {
2095
+ nearest = entry;
2096
+ nearestDistance = distance;
2097
+ }
2098
+ }
2099
+ return nearestDistance <= 140 ? nearest : null;
2100
+ }
2101
+ function findNearestAtCandidate(scene, selectedObjectId, pointer) {
2102
+ let directObjectCandidate = null;
2103
+ for (const entry of scene.objects) {
2104
+ if (entry.hidden || entry.objectId === selectedObjectId) {
2105
+ continue;
2106
+ }
2107
+ const distance = Math.hypot(pointer.x - entry.x, pointer.y - entry.y);
2108
+ const snapRadius = Math.max(entry.visualRadius + 16, 28);
2109
+ if (distance <= snapRadius) {
2110
+ if (!directObjectCandidate || distance < directObjectCandidate.distance) {
2111
+ directObjectCandidate = {
2112
+ reference: parseAtReferenceString(entry.objectId),
2113
+ x: entry.x,
2114
+ y: entry.y,
2115
+ distance,
2116
+ };
2117
+ }
2118
+ }
2119
+ }
2120
+ if (directObjectCandidate) {
2121
+ return {
2122
+ reference: directObjectCandidate.reference,
2123
+ x: directObjectCandidate.x,
2124
+ y: directObjectCandidate.y,
2125
+ };
2126
+ }
2127
+ const candidates = [];
2128
+ for (const entry of scene.objects) {
2129
+ if (entry.hidden || entry.objectId === selectedObjectId) {
2130
+ continue;
2131
+ }
2132
+ candidates.push({
2133
+ reference: parseAtReferenceString(entry.objectId),
2134
+ x: entry.x,
2135
+ y: entry.y,
2136
+ });
2137
+ }
2138
+ for (const orbit of scene.orbitVisuals) {
2139
+ if (orbit.hidden || orbit.objectId === selectedObjectId) {
2140
+ continue;
2141
+ }
2142
+ const parent = scene.objects.find((entry) => entry.objectId === orbit.parentId && !entry.hidden);
2143
+ const secondary = scene.objects.find((entry) => entry.objectId === orbit.objectId && !entry.hidden);
2144
+ if (!parent || !secondary) {
2145
+ continue;
2146
+ }
2147
+ for (const point of ["L1", "L2", "L3", "L4", "L5"]) {
2148
+ const position = computeLagrangeCandidatePosition(parent, secondary, point);
2149
+ candidates.push({
2150
+ reference: parseAtReferenceString(`${orbit.objectId}:${point}`),
2151
+ x: position.x,
2152
+ y: position.y,
2153
+ });
2154
+ }
2155
+ }
2156
+ let nearest = null;
2157
+ let nearestDistance = Number.POSITIVE_INFINITY;
2158
+ for (const candidate of candidates) {
2159
+ const distance = Math.hypot(pointer.x - candidate.x, pointer.y - candidate.y);
2160
+ if (distance < nearestDistance) {
2161
+ nearest = candidate;
2162
+ nearestDistance = distance;
2163
+ }
2164
+ }
2165
+ return nearestDistance <= 140 ? nearest : null;
2166
+ }
2167
+ function computeLagrangeCandidatePosition(primary, secondary, point) {
2168
+ const dx = secondary.x - primary.x;
2169
+ const dy = secondary.y - primary.y;
2170
+ const distance = Math.hypot(dx, dy) || 1;
2171
+ const ux = dx / distance;
2172
+ const uy = dy / distance;
2173
+ const nx = -uy;
2174
+ const ny = ux;
2175
+ const offset = clampNumber(distance * 0.25, 24, 68);
2176
+ switch (point) {
2177
+ case "L1":
2178
+ return {
2179
+ x: secondary.x - ux * offset,
2180
+ y: secondary.y - uy * offset,
2181
+ };
2182
+ case "L2":
2183
+ return {
2184
+ x: secondary.x + ux * offset,
2185
+ y: secondary.y + uy * offset,
2186
+ };
2187
+ case "L3":
2188
+ return {
2189
+ x: primary.x - ux * offset,
2190
+ y: primary.y - uy * offset,
2191
+ };
2192
+ case "L4":
2193
+ return {
2194
+ x: secondary.x + (ux * 0.5 - nx * 0.8660254) * offset,
2195
+ y: secondary.y + (uy * 0.5 - ny * 0.8660254) * offset,
2196
+ };
2197
+ case "L5":
2198
+ return {
2199
+ x: secondary.x + (ux * 0.5 + nx * 0.8660254) * offset,
2200
+ y: secondary.y + (uy * 0.5 + ny * 0.8660254) * offset,
2201
+ };
2202
+ }
2203
+ }
2204
+ function parseAtReferenceString(target) {
2205
+ const pairedMatch = target.match(/^([A-Za-z0-9._-]+)-([A-Za-z0-9._-]+):(L[1-5])$/);
2206
+ if (pairedMatch) {
2207
+ return {
2208
+ kind: "lagrange",
2209
+ primary: pairedMatch[1],
2210
+ secondary: pairedMatch[2],
2211
+ point: pairedMatch[3],
2212
+ };
2213
+ }
2214
+ const simpleMatch = target.match(/^([A-Za-z0-9._-]+):(L[1-5])$/);
2215
+ if (simpleMatch) {
2216
+ return {
2217
+ kind: "lagrange",
2218
+ primary: simpleMatch[1],
2219
+ secondary: null,
2220
+ point: simpleMatch[2],
2221
+ };
2222
+ }
2223
+ const anchorMatch = target.match(/^([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)$/);
2224
+ if (anchorMatch) {
2225
+ return {
2226
+ kind: "anchor",
2227
+ objectId: anchorMatch[1],
2228
+ anchor: anchorMatch[2],
2229
+ };
2230
+ }
2231
+ return {
2232
+ kind: "named",
2233
+ name: target,
2234
+ };
2235
+ }
2236
+ function formatAtReference(reference) {
2237
+ switch (reference.kind) {
2238
+ case "lagrange":
2239
+ return reference.secondary
2240
+ ? `${reference.primary}-${reference.secondary}:${reference.point}`
2241
+ : `${reference.primary}:${reference.point}`;
2242
+ case "anchor":
2243
+ return `${reference.objectId}:${reference.anchor}`;
2244
+ case "named":
2245
+ return reference.name;
2246
+ }
2247
+ }
2248
+ function collectDocumentDiagnostics(document) {
2249
+ return validateAtlasDocumentWithDiagnostics(document);
2250
+ }
2251
+ function mergeDiagnostics(primary, secondary) {
2252
+ const seen = new Set();
2253
+ const merged = [];
2254
+ for (const entry of [...primary, ...secondary]) {
2255
+ const key = `${entry.diagnostic.code}:${entry.diagnostic.message}:${selectionKey(entry.path)}`;
2256
+ if (seen.has(key)) {
2257
+ continue;
2258
+ }
2259
+ seen.add(key);
2260
+ merged.push(cloneResolvedDiagnostic(entry));
2261
+ }
2262
+ return merged;
2263
+ }
2264
+ function buildDiagnosticBuckets(entries) {
2265
+ const buckets = new Map();
2266
+ for (const entry of entries) {
2267
+ const key = selectionKey(entry.path) ??
2268
+ (entry.diagnostic.objectId ? selectionKey({ kind: "object", id: entry.diagnostic.objectId }) : null);
2269
+ if (!key) {
2270
+ continue;
2271
+ }
2272
+ const bucket = buckets.get(key) ?? { errors: 0, warnings: 0 };
2273
+ if (entry.diagnostic.severity === "error") {
2274
+ bucket.errors += 1;
2275
+ }
2276
+ else if (entry.diagnostic.severity === "warning") {
2277
+ bucket.warnings += 1;
2278
+ }
2279
+ buckets.set(key, bucket);
2280
+ }
2281
+ return buckets;
2282
+ }
2283
+ function formatDiagnosticLocation(entry) {
2284
+ if (entry.diagnostic.line !== undefined && entry.diagnostic.column !== undefined) {
2285
+ return `Line ${entry.diagnostic.line}:${entry.diagnostic.column}`;
2286
+ }
2287
+ if (entry.diagnostic.line !== undefined) {
2288
+ return `Line ${entry.diagnostic.line}`;
2289
+ }
2290
+ return null;
2291
+ }
2292
+ function mapDiagnosticFieldToInputNames(selection, field) {
2293
+ if (!field) {
2294
+ return [];
2295
+ }
2296
+ switch (selection.kind) {
2297
+ case "system":
2298
+ if (field === "id") {
2299
+ return ["system-id"];
2300
+ }
2301
+ if (field === "title") {
2302
+ return ["system-title"];
2303
+ }
2304
+ return [];
2305
+ case "defaults":
2306
+ switch (field) {
2307
+ case "view":
2308
+ return ["defaults-view"];
2309
+ case "scale":
2310
+ return ["defaults-scale"];
2311
+ case "units":
2312
+ return ["defaults-units"];
2313
+ case "preset":
2314
+ return ["defaults-preset"];
2315
+ case "theme":
2316
+ return ["defaults-theme"];
2317
+ default:
2318
+ return [];
2319
+ }
2320
+ case "metadata":
2321
+ return field === "key" ? ["metadata-key"] : ["metadata-value"];
2322
+ case "group":
2323
+ switch (field) {
2324
+ case "id":
2325
+ return ["group-id"];
2326
+ case "label":
2327
+ return ["group-label"];
2328
+ case "summary":
2329
+ return ["group-summary"];
2330
+ case "color":
2331
+ return ["group-color"];
2332
+ case "tags":
2333
+ return ["group-tags"];
2334
+ case "hidden":
2335
+ return ["group-hidden"];
2336
+ default:
2337
+ return [];
2338
+ }
2339
+ case "viewpoint":
2340
+ switch (field) {
2341
+ case "id":
2342
+ return ["viewpoint-id"];
2343
+ case "label":
2344
+ return ["viewpoint-label"];
2345
+ case "summary":
2346
+ return ["viewpoint-summary"];
2347
+ case "focusObjectId":
2348
+ return ["viewpoint-focus"];
2349
+ case "selectedObjectId":
2350
+ return ["viewpoint-select"];
2351
+ case "projection":
2352
+ return ["viewpoint-projection"];
2353
+ case "preset":
2354
+ return ["viewpoint-preset"];
2355
+ case "zoom":
2356
+ return ["viewpoint-zoom"];
2357
+ case "rotationDeg":
2358
+ return ["viewpoint-rotation"];
2359
+ default:
2360
+ return [];
2361
+ }
2362
+ case "annotation":
2363
+ switch (field) {
2364
+ case "id":
2365
+ return ["annotation-id"];
2366
+ case "label":
2367
+ return ["annotation-label"];
2368
+ case "targetObjectId":
2369
+ return ["annotation-target"];
2370
+ case "sourceObjectId":
2371
+ return ["annotation-source"];
2372
+ case "body":
2373
+ return ["annotation-body"];
2374
+ case "tags":
2375
+ return ["annotation-tags"];
2376
+ default:
2377
+ return [];
2378
+ }
2379
+ case "relation":
2380
+ switch (field) {
2381
+ case "id":
2382
+ return ["relation-id"];
2383
+ case "from":
2384
+ return ["relation-from"];
2385
+ case "to":
2386
+ return ["relation-to"];
2387
+ case "kind":
2388
+ return ["relation-kind"];
2389
+ case "label":
2390
+ return ["relation-label"];
2391
+ case "summary":
2392
+ return ["relation-summary"];
2393
+ case "tags":
2394
+ return ["relation-tags"];
2395
+ case "color":
2396
+ return ["relation-color"];
2397
+ case "hidden":
2398
+ return ["relation-hidden"];
2399
+ default:
2400
+ return [];
2401
+ }
2402
+ case "object":
2403
+ if (field === "id") {
2404
+ return ["object-id"];
2405
+ }
2406
+ if (field === "type") {
2407
+ return ["object-type"];
2408
+ }
2409
+ if (field === "placement") {
2410
+ return ["placement-mode"];
2411
+ }
2412
+ if (field === "description") {
2413
+ return ["info-description"];
2414
+ }
2415
+ if (field === "reference") {
2416
+ return ["placement-target"];
2417
+ }
2418
+ if (field === "descriptor") {
2419
+ return ["placement-free"];
2420
+ }
2421
+ if (field === "target") {
2422
+ return ["placement-target"];
2423
+ }
2424
+ if (PLACEMENT_DIAGNOSTIC_FIELDS.has(field)) {
2425
+ return [`placement-${field}`];
2426
+ }
2427
+ return [`prop-${field}`];
2428
+ }
2429
+ }
2430
+ function buildEmbedMarkup(source, document) {
2431
+ return renderWorldOrbitBlock(source, {
2432
+ mode: "interactive",
2433
+ preset: document.system?.defaults.preset ?? "atlas-card",
2434
+ projection: document.system?.defaults.view ?? "topdown",
2435
+ });
2436
+ }
2437
+ function describePath(path) {
2438
+ switch (path.kind) {
2439
+ case "system":
2440
+ return "System";
2441
+ case "defaults":
2442
+ return "Defaults";
2443
+ case "metadata":
2444
+ return `Metadata: ${path.key ?? ""}`;
2445
+ case "group":
2446
+ return `Group: ${path.id ?? ""}`;
2447
+ case "object":
2448
+ return `Object: ${path.id ?? ""}`;
2449
+ case "viewpoint":
2450
+ return `Viewpoint: ${path.id ?? ""}`;
2451
+ case "annotation":
2452
+ return `Annotation: ${path.id ?? ""}`;
2453
+ case "relation":
2454
+ return `Relation: ${path.id ?? ""}`;
2455
+ }
2456
+ }
2457
+ function selectionKey(path) {
2458
+ return path ? `${path.kind}:${path.id ?? path.key ?? ""}` : null;
2459
+ }
2460
+ function compareObjects(left, right) {
2461
+ return left.id.localeCompare(right.id);
2462
+ }
2463
+ function createUniqueId(prefix, existing) {
2464
+ const safePrefix = prefix.trim() || "item";
2465
+ let counter = 1;
2466
+ let candidate = safePrefix;
2467
+ while (existing.includes(candidate)) {
2468
+ counter += 1;
2469
+ candidate = `${safePrefix}-${counter}`;
2470
+ }
2471
+ return candidate;
2472
+ }
2473
+ function humanizeIdentifier(value) {
2474
+ return value
2475
+ .split(/[-_]+/)
2476
+ .filter(Boolean)
2477
+ .map((segment) => segment[0].toUpperCase() + segment.slice(1))
2478
+ .join(" ");
2479
+ }
2480
+ function cloneResolvedDiagnostic(diagnostic) {
2481
+ return {
2482
+ diagnostic: { ...diagnostic.diagnostic },
2483
+ path: diagnostic.path ? { ...diagnostic.path } : null,
2484
+ };
2485
+ }
2486
+ function readStringProperty(value) {
2487
+ return typeof value === "string" ? value : "";
2488
+ }
2489
+ function readTagsProperty(value) {
2490
+ return Array.isArray(value) ? value.join(" ") : "";
2491
+ }
2492
+ function readUnitProperty(value) {
2493
+ return value && typeof value === "object" && "value" in value
2494
+ ? formatUnitValue(value)
2495
+ : "";
2496
+ }
2497
+ function readNumberProperty(value) {
2498
+ return typeof value === "number" ? String(value) : "";
2499
+ }
2500
+ function formatUnitValue(value) {
2501
+ return `${value.value}${value.unit ?? ""}`;
2502
+ }
2503
+ function roundNumber(value, decimals) {
2504
+ const factor = 10 ** decimals;
2505
+ return Math.round(value * factor) / factor;
2506
+ }
2507
+ function clampNumber(value, min, max) {
2508
+ return Math.min(Math.max(value, min), max);
2509
+ }
2510
+ function normalizeDegrees(value) {
2511
+ let normalized = value % 360;
2512
+ if (normalized < 0) {
2513
+ normalized += 360;
2514
+ }
2515
+ return normalized;
2516
+ }
2517
+ function normalizeFreeDistanceUnit(unit) {
2518
+ switch (unit) {
2519
+ case "au":
2520
+ case "km":
2521
+ case "re":
2522
+ case "sol":
2523
+ case null:
2524
+ return unit;
2525
+ default:
2526
+ return null;
2527
+ }
2528
+ }
2529
+ function unitValueToDistanceMetric(value) {
2530
+ if (!value) {
2531
+ return null;
2532
+ }
2533
+ switch (value.unit) {
2534
+ case "au":
2535
+ return value.value;
2536
+ case "km":
2537
+ return value.value / AU_IN_KM;
2538
+ case "re":
2539
+ return (value.value * EARTH_RADIUS_IN_KM) / AU_IN_KM;
2540
+ case "sol":
2541
+ return (value.value * SOLAR_RADIUS_IN_KM) / AU_IN_KM;
2542
+ default:
2543
+ return value.value;
2544
+ }
2545
+ }
2546
+ function distanceMetricToUnitValue(metric, unit) {
2547
+ switch (unit) {
2548
+ case "km":
2549
+ return { value: roundNumber(metric * AU_IN_KM, 0), unit };
2550
+ case "re":
2551
+ return { value: roundNumber((metric * AU_IN_KM) / EARTH_RADIUS_IN_KM, 3), unit };
2552
+ case "sol":
2553
+ return { value: roundNumber((metric * AU_IN_KM) / SOLAR_RADIUS_IN_KM, 4), unit };
2554
+ case "au":
2555
+ return { value: roundNumber(metric, 3), unit };
2556
+ default:
2557
+ return { value: roundNumber(metric, 2), unit: null };
2558
+ }
2559
+ }
2560
+ function orbitMetricToRadiusPx(metric, innerPx, stepPx) {
2561
+ return innerPx + stepPx * log2(Math.max(metric, 0) + 1);
2562
+ }
2563
+ function orbitRadiusPxToMetric(radiusPx, innerPx, stepPx) {
2564
+ if (radiusPx <= innerPx) {
2565
+ return 0;
2566
+ }
2567
+ return Math.pow(2, (radiusPx - innerPx) / Math.max(stepPx, 0.0001)) - 1;
2568
+ }
2569
+ function layoutPresetSpacingForScene(layoutPreset) {
2570
+ switch (layoutPreset) {
2571
+ case "compact":
2572
+ return 0.84;
2573
+ case "presentation":
2574
+ return 1.2;
2575
+ default:
2576
+ return 1;
2577
+ }
2578
+ }
2579
+ function log2(value) {
2580
+ return Math.log(value) / Math.log(2);
2581
+ }
2582
+ function escapeHtml(value) {
2583
+ return value
2584
+ .replaceAll("&", "&amp;")
2585
+ .replaceAll("<", "&lt;")
2586
+ .replaceAll(">", "&gt;")
2587
+ .replaceAll('"', "&quot;");
2588
+ }
2589
+ function escapeAttribute(value) {
2590
+ return escapeHtml(value);
2591
+ }
2592
+ function ensureBrowserEnvironment(container) {
2593
+ if (typeof window === "undefined" || typeof document === "undefined") {
2594
+ throw new Error("createWorldOrbitEditor can only run in a browser environment.");
2595
+ }
2596
+ if (!(container instanceof HTMLElement)) {
2597
+ throw new Error("WorldOrbit editor requires an HTMLElement container.");
2598
+ }
2599
+ }
2600
+ function queryRequired(container, selector) {
2601
+ const found = container.querySelector(selector);
2602
+ if (!found) {
2603
+ throw new Error(`WorldOrbit editor failed to initialize selector "${selector}".`);
2604
+ }
2605
+ return found;
2606
+ }
2607
+ function installEditorStyles() {
2608
+ if (document.getElementById(STYLE_ID)) {
2609
+ return;
2610
+ }
2611
+ const style = document.createElement("style");
2612
+ style.id = STYLE_ID;
2613
+ style.textContent = `
2614
+ .wo-editor {
2615
+ --wo-editor-sidebar-width: 280px;
2616
+ --wo-editor-inspector-width: 360px;
2617
+ --wo-editor-source-height: 280px;
2618
+ }
2619
+ .wo-editor-shell { display: grid; gap: 16px; min-width: 0; }
2620
+ .wo-editor-toolbar { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; min-width: 0; }
2621
+ .wo-editor-toolbar-group { display: flex; gap: 10px; flex-wrap: wrap; min-width: 0; }
2622
+ .wo-editor-toolbar button, .wo-editor-toolbar select {
2623
+ border: 1px solid rgba(240, 180, 100, 0.2);
2624
+ border-radius: 999px;
2625
+ background: rgba(240, 180, 100, 0.08);
2626
+ color: #edf6ff;
2627
+ font: 600 13px/1.4 "Segoe UI Variable", "Segoe UI", sans-serif;
2628
+ padding: 10px 14px;
2629
+ }
2630
+ .wo-editor button:focus-visible,
2631
+ .wo-editor select:focus-visible,
2632
+ .wo-editor input:focus-visible,
2633
+ .wo-editor textarea:focus-visible {
2634
+ outline: 2px solid rgba(255, 214, 138, 0.95);
2635
+ outline-offset: 2px;
2636
+ }
2637
+ .wo-editor-toolbar button:disabled { opacity: 0.45; cursor: not-allowed; }
2638
+ .wo-editor-status {
2639
+ display: flex;
2640
+ flex-wrap: wrap;
2641
+ gap: 10px;
2642
+ min-width: 0;
2643
+ }
2644
+ .wo-editor-status-pill {
2645
+ border: 1px solid rgba(255,255,255,0.08);
2646
+ border-radius: 999px;
2647
+ background: rgba(255,255,255,0.04);
2648
+ color: #edf6ff;
2649
+ font: 600 12px/1.4 "Segoe UI Variable", "Segoe UI", sans-serif;
2650
+ padding: 8px 12px;
2651
+ }
2652
+ .wo-editor-status-pill.is-clean { border-color: rgba(120, 255, 195, 0.22); color: #c7ffe5; }
2653
+ .wo-editor-status-pill.is-dirty { border-color: rgba(240, 180, 100, 0.28); color: #ffd58a; }
2654
+ .wo-editor-status-pill.is-warning { border-color: rgba(240, 180, 100, 0.28); color: #ffd58a; }
2655
+ .wo-editor-status-pill.is-error { border-color: rgba(255, 120, 120, 0.3); color: #ffb2b2; }
2656
+ .wo-editor-main {
2657
+ display: grid;
2658
+ grid-template-columns:
2659
+ minmax(220px, var(--wo-editor-sidebar-width))
2660
+ minmax(0, 1fr)
2661
+ minmax(280px, var(--wo-editor-inspector-width));
2662
+ gap: 16px;
2663
+ min-width: 0;
2664
+ align-items: start;
2665
+ }
2666
+ .wo-editor-sidebar, .wo-editor-stage-panel, .wo-editor-preview { display: grid; gap: 16px; min-width: 0; }
2667
+ .wo-editor-panel {
2668
+ border-radius: 24px;
2669
+ border: 1px solid rgba(255,255,255,0.08);
2670
+ background: rgba(7, 16, 25, 0.72);
2671
+ padding: 18px;
2672
+ min-width: 0;
2673
+ }
2674
+ .wo-editor-panel h2 {
2675
+ margin: 0 0 14px;
2676
+ color: #edf6ff;
2677
+ font: 700 14px/1.2 "Segoe UI Variable Display", "Segoe UI", sans-serif;
2678
+ text-transform: uppercase;
2679
+ letter-spacing: 0.08em;
2680
+ }
2681
+ .wo-editor-panel h3 {
2682
+ margin: 0 0 12px;
2683
+ color: rgba(237, 246, 255, 0.82);
2684
+ font: 700 12px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
2685
+ text-transform: uppercase;
2686
+ letter-spacing: 0.08em;
2687
+ }
2688
+ .wo-editor-panel-section,
2689
+ .wo-editor-inspector-section {
2690
+ min-width: 0;
2691
+ }
2692
+ .wo-editor-panel-section > summary,
2693
+ .wo-editor-inspector-section > summary {
2694
+ display: flex;
2695
+ align-items: center;
2696
+ justify-content: space-between;
2697
+ gap: 10px;
2698
+ cursor: pointer;
2699
+ list-style: none;
2700
+ color: #edf6ff;
2701
+ font: 700 14px/1.2 "Segoe UI Variable Display", "Segoe UI", sans-serif;
2702
+ text-transform: uppercase;
2703
+ letter-spacing: 0.08em;
2704
+ }
2705
+ .wo-editor-inspector-section > summary {
2706
+ font: 700 12px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
2707
+ color: rgba(237, 246, 255, 0.9);
2708
+ }
2709
+ .wo-editor-panel-section > summary::-webkit-details-marker,
2710
+ .wo-editor-inspector-section > summary::-webkit-details-marker,
2711
+ .wo-editor-field-help > summary::-webkit-details-marker {
2712
+ display: none;
2713
+ }
2714
+ .wo-editor-panel-section > summary::after,
2715
+ .wo-editor-inspector-section > summary::after {
2716
+ content: "▾";
2717
+ color: rgba(240, 180, 100, 0.86);
2718
+ font-size: 12px;
2719
+ transition: transform 0.18s ease;
2720
+ }
2721
+ .wo-editor-panel-section:not([open]) > summary::after,
2722
+ .wo-editor-inspector-section:not([open]) > summary::after {
2723
+ transform: rotate(-90deg);
2724
+ }
2725
+ .wo-editor-panel-section-body {
2726
+ display: grid;
2727
+ gap: 16px;
2728
+ margin-top: 14px;
2729
+ min-width: 0;
2730
+ }
2731
+ .wo-editor-inspector-section-body {
2732
+ display: grid;
2733
+ gap: 12px;
2734
+ margin-top: 12px;
2735
+ min-width: 0;
2736
+ }
2737
+ .wo-editor[data-wo-show-inspector="false"] [data-editor-pane="inspector"] { display: none; }
2738
+ .wo-editor[data-wo-show-text-pane="false"] [data-editor-pane="text"] { display: none; }
2739
+ .wo-editor[data-wo-show-preview="false"] [data-editor-pane="preview"] { display: none; }
2740
+ .wo-editor-stage-shell {
2741
+ position: relative;
2742
+ min-width: 0;
2743
+ border-radius: 26px;
2744
+ overflow: hidden;
2745
+ border: 1px solid rgba(255,255,255,0.08);
2746
+ background: rgba(8, 17, 28, 0.9);
2747
+ }
2748
+ .wo-editor-stage { min-height: 620px; min-width: 0; }
2749
+ .wo-editor-overlay { position: absolute; inset: 0; pointer-events: none; }
2750
+ .wo-editor-overlay > * { pointer-events: auto; }
2751
+ .wo-editor-handle {
2752
+ position: absolute;
2753
+ transform: translate(-50%, -50%);
2754
+ border: 0;
2755
+ border-radius: 999px;
2756
+ background: rgba(255, 214, 138, 0.92);
2757
+ color: #071019;
2758
+ cursor: grab;
2759
+ font: 700 11px/1 "Segoe UI Variable", "Segoe UI", sans-serif;
2760
+ padding: 8px 10px;
2761
+ box-shadow: 0 10px 24px rgba(0,0,0,0.24);
2762
+ }
2763
+ .wo-editor-hint {
2764
+ position: absolute;
2765
+ transform: translate(-50%, -50%);
2766
+ padding: 4px 8px;
2767
+ border-radius: 999px;
2768
+ background: rgba(18, 39, 58, 0.84);
2769
+ color: #edf6ff;
2770
+ font: 600 11px/1.3 "Segoe UI Variable", "Segoe UI", sans-serif;
2771
+ white-space: nowrap;
2772
+ box-shadow: 0 6px 18px rgba(0,0,0,0.22);
2773
+ }
2774
+ .wo-editor-hint.is-subtle { background: rgba(18, 39, 58, 0.64); color: rgba(237, 246, 255, 0.78); }
2775
+ .wo-editor-hint-note { left: 16px; top: 16px; transform: none; }
2776
+ .wo-editor-overlay-diagnostics {
2777
+ position: absolute;
2778
+ right: 16px;
2779
+ top: 16px;
2780
+ display: grid;
2781
+ gap: 8px;
2782
+ max-width: min(320px, calc(100% - 32px));
2783
+ }
2784
+ .wo-editor-overlay-diagnostic {
2785
+ border-radius: 14px;
2786
+ padding: 10px 12px;
2787
+ background: rgba(10, 20, 32, 0.92);
2788
+ box-shadow: 0 12px 30px rgba(0,0,0,0.28);
2789
+ }
2790
+ .wo-editor-overlay-diagnostic strong {
2791
+ display: block;
2792
+ margin-bottom: 4px;
2793
+ font: 700 11px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
2794
+ letter-spacing: 0.08em;
2795
+ text-transform: uppercase;
2796
+ }
2797
+ .wo-editor-overlay-diagnostic p {
2798
+ margin: 0;
2799
+ color: #edf6ff;
2800
+ font: 500 12px/1.4 "Segoe UI Variable", "Segoe UI", sans-serif;
2801
+ }
2802
+ .wo-editor-overlay-diagnostic-error { border: 1px solid rgba(255, 120, 120, 0.28); }
2803
+ .wo-editor-overlay-diagnostic-warning { border: 1px solid rgba(240, 180, 100, 0.24); }
2804
+ .wo-editor-outline { display: grid; gap: 14px; }
2805
+ .wo-editor-outline-section { display: grid; gap: 8px; }
2806
+ .wo-editor-outline-section h3 {
2807
+ margin: 0;
2808
+ color: rgba(237,246,255,0.68);
2809
+ font: 600 11px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
2810
+ text-transform: uppercase;
2811
+ letter-spacing: 0.08em;
2812
+ }
2813
+ .wo-editor-outline-item {
2814
+ border: 1px solid transparent;
2815
+ border-radius: 16px;
2816
+ background: rgba(255,255,255,0.04);
2817
+ color: #edf6ff;
2818
+ cursor: pointer;
2819
+ font: 500 13px/1.4 "Segoe UI Variable", "Segoe UI", sans-serif;
2820
+ padding: 10px 12px;
2821
+ text-align: left;
2822
+ display: flex;
2823
+ align-items: center;
2824
+ justify-content: space-between;
2825
+ gap: 10px;
2826
+ }
2827
+ .wo-editor-outline-item.is-active {
2828
+ border-color: rgba(255, 214, 138, 0.34);
2829
+ background: rgba(255, 214, 138, 0.12);
2830
+ color: #ffdda9;
2831
+ }
2832
+ .wo-editor-outline-badge {
2833
+ min-width: 22px;
2834
+ border-radius: 999px;
2835
+ background: rgba(240, 180, 100, 0.16);
2836
+ color: #ffd58a;
2837
+ font: 700 11px/1 "Segoe UI Variable", "Segoe UI", sans-serif;
2838
+ padding: 5px 7px;
2839
+ text-align: center;
2840
+ }
2841
+ .wo-editor-outline-badge.is-error {
2842
+ background: rgba(255, 120, 120, 0.18);
2843
+ color: #ffb2b2;
2844
+ }
2845
+ .wo-editor-diagnostics { display: grid; gap: 10px; }
2846
+ .wo-editor-diagnostic {
2847
+ display: grid;
2848
+ gap: 4px;
2849
+ border-radius: 16px;
2850
+ padding: 12px;
2851
+ background: rgba(255,255,255,0.04);
2852
+ color: #edf6ff;
2853
+ font: 500 12px/1.45 "Segoe UI Variable", "Segoe UI", sans-serif;
2854
+ }
2855
+ .wo-editor-diagnostic strong { font-size: 11px; letter-spacing: 0.08em; }
2856
+ .wo-editor-diagnostic p { margin: 0; color: rgba(237,246,255,0.82); }
2857
+ .wo-editor-diagnostic-warning { border: 1px solid rgba(240, 180, 100, 0.22); }
2858
+ .wo-editor-diagnostic-error { border: 1px solid rgba(255, 120, 120, 0.24); }
2859
+ .wo-editor-inspector-summary {
2860
+ display: grid;
2861
+ gap: 10px;
2862
+ margin-bottom: 14px;
2863
+ }
2864
+ .wo-editor-form { display: grid; gap: 12px; min-width: 0; }
2865
+ .wo-editor-inspector-section {
2866
+ border: 1px solid rgba(255,255,255,0.08);
2867
+ border-radius: 18px;
2868
+ padding: 14px;
2869
+ background: rgba(255,255,255,0.02);
2870
+ }
2871
+ .wo-editor-field { display: grid; gap: 6px; }
2872
+ .wo-editor-field-label,
2873
+ .wo-editor-fieldset legend {
2874
+ display: flex;
2875
+ align-items: center;
2876
+ justify-content: space-between;
2877
+ gap: 10px;
2878
+ }
2879
+ .wo-editor-field-label,
2880
+ .wo-editor-checkbox .wo-editor-field-label,
2881
+ .wo-editor-fieldset legend {
2882
+ color: rgba(237,246,255,0.72);
2883
+ font: 600 11px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
2884
+ text-transform: uppercase;
2885
+ letter-spacing: 0.08em;
2886
+ }
2887
+ .wo-editor-field input, .wo-editor-field select, .wo-editor-field textarea, .wo-editor-source {
2888
+ width: 100%;
2889
+ min-width: 0;
2890
+ border: 1px solid rgba(255,255,255,0.08);
2891
+ border-radius: 14px;
2892
+ background: rgba(12, 26, 39, 0.84);
2893
+ color: #edf6ff;
2894
+ font: 500 13px/1.5 "Segoe UI Variable", "Segoe UI", sans-serif;
2895
+ padding: 10px 12px;
2896
+ box-sizing: border-box;
2897
+ }
2898
+ .wo-editor-field.has-error input,
2899
+ .wo-editor-field.has-error select,
2900
+ .wo-editor-field.has-error textarea,
2901
+ .wo-editor-checkbox.has-error {
2902
+ border-color: rgba(255, 120, 120, 0.44);
2903
+ }
2904
+ .wo-editor-field.has-warning input,
2905
+ .wo-editor-field.has-warning select,
2906
+ .wo-editor-field.has-warning textarea,
2907
+ .wo-editor-checkbox.has-warning {
2908
+ border-color: rgba(240, 180, 100, 0.42);
2909
+ }
2910
+ .wo-editor-field textarea, .wo-editor-source { min-height: 110px; resize: vertical; }
2911
+ .wo-editor-source {
2912
+ min-height: var(--wo-editor-source-height);
2913
+ font-family: "Cascadia Code", "Consolas", monospace;
2914
+ white-space: pre;
2915
+ overflow: auto;
2916
+ }
2917
+ .wo-editor-field-note {
2918
+ color: rgba(237,246,255,0.72);
2919
+ font: 500 12px/1.45 "Segoe UI Variable", "Segoe UI", sans-serif;
2920
+ }
2921
+ .wo-editor-field-note.is-error { color: #ffb2b2; }
2922
+ .wo-editor-field-note.is-warning { color: #ffd58a; }
2923
+ .wo-editor-fieldset {
2924
+ display: grid;
2925
+ gap: 10px;
2926
+ border: 1px solid rgba(255,255,255,0.08);
2927
+ border-radius: 18px;
2928
+ padding: 14px;
2929
+ min-width: 0;
2930
+ }
2931
+ .wo-editor-checkbox {
2932
+ display: flex;
2933
+ gap: 10px;
2934
+ align-items: flex-start;
2935
+ color: #edf6ff;
2936
+ font: 500 13px/1.4 "Segoe UI Variable", "Segoe UI", sans-serif;
2937
+ }
2938
+ .wo-editor-checkbox input {
2939
+ margin-top: 2px;
2940
+ }
2941
+ .wo-editor-field-help {
2942
+ position: relative;
2943
+ flex: 0 0 auto;
2944
+ }
2945
+ .wo-editor-field-help > summary {
2946
+ display: inline-flex;
2947
+ align-items: center;
2948
+ justify-content: center;
2949
+ width: 20px;
2950
+ height: 20px;
2951
+ border-radius: 999px;
2952
+ border: 1px solid rgba(240, 180, 100, 0.28);
2953
+ background: rgba(240, 180, 100, 0.12);
2954
+ color: #ffdda9;
2955
+ cursor: pointer;
2956
+ font: 700 11px/1 "Segoe UI Variable", "Segoe UI", sans-serif;
2957
+ list-style: none;
2958
+ }
2959
+ .wo-editor-field-help-card {
2960
+ display: grid;
2961
+ gap: 8px;
2962
+ margin-top: 8px;
2963
+ padding: 10px 12px;
2964
+ border-radius: 14px;
2965
+ border: 1px solid rgba(240, 180, 100, 0.2);
2966
+ background: rgba(12, 26, 39, 0.92);
2967
+ color: rgba(237, 246, 255, 0.86);
2968
+ text-transform: none;
2969
+ letter-spacing: normal;
2970
+ font: 500 12px/1.45 "Segoe UI Variable", "Segoe UI", sans-serif;
2971
+ }
2972
+ .wo-editor-field-help-card p {
2973
+ margin: 0;
2974
+ }
2975
+ .wo-editor-field-help-chips {
2976
+ display: flex;
2977
+ flex-wrap: wrap;
2978
+ gap: 6px;
2979
+ }
2980
+ .wo-editor-field-help-chips span {
2981
+ border-radius: 999px;
2982
+ padding: 4px 8px;
2983
+ background: rgba(255,255,255,0.06);
2984
+ color: #edf6ff;
2985
+ font: 600 11px/1.3 "Segoe UI Variable", "Segoe UI", sans-serif;
2986
+ text-transform: none;
2987
+ letter-spacing: normal;
2988
+ }
2989
+ .wo-editor-preview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2990
+ .wo-editor-preview-card {
2991
+ border-radius: 18px;
2992
+ border: 1px solid rgba(255,255,255,0.08);
2993
+ background: rgba(255,255,255,0.03);
2994
+ padding: 14px;
2995
+ min-width: 0;
2996
+ }
2997
+ .wo-editor-preview-visual {
2998
+ min-height: 240px;
2999
+ overflow: auto;
3000
+ }
3001
+ .wo-editor-preview-markup {
3002
+ margin: 0;
3003
+ min-height: 240px;
3004
+ white-space: pre-wrap;
3005
+ overflow-wrap: anywhere;
3006
+ color: #edf6ff;
3007
+ font: 12px/1.5 "Cascadia Code", "Consolas", monospace;
3008
+ }
3009
+ .wo-editor-source-diagnostics {
3010
+ display: grid;
3011
+ gap: 10px;
3012
+ margin-top: 14px;
3013
+ }
3014
+ .wo-editor-empty {
3015
+ margin: 0;
3016
+ color: rgba(237,246,255,0.68);
3017
+ font: 500 12px/1.5 "Segoe UI Variable", "Segoe UI", sans-serif;
3018
+ }
3019
+ .wo-editor-live-region {
3020
+ position: absolute;
3021
+ width: 1px;
3022
+ height: 1px;
3023
+ padding: 0;
3024
+ margin: -1px;
3025
+ overflow: hidden;
3026
+ clip: rect(0, 0, 0, 0);
3027
+ white-space: nowrap;
3028
+ border: 0;
3029
+ }
3030
+ @media (max-width: 1280px) {
3031
+ .wo-editor-main { grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); }
3032
+ .wo-editor-inspector { grid-column: 1 / -1; }
3033
+ .wo-editor-preview { grid-template-columns: 1fr; }
3034
+ }
3035
+ @media (max-width: 960px) {
3036
+ .wo-editor-main { grid-template-columns: 1fr; }
3037
+ .wo-editor-stage { min-height: 440px; }
3038
+ .wo-editor-status { flex-direction: column; }
3039
+ }
3040
+ `;
3041
+ document.head.append(style);
3042
+ }