mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.
- Simplify src/i18n: drop namespaces, the configurable locale pair
(GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
and markup, and fails on missing, unused, placeholder-mismatched or
wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
merge resurrected in docs/CURRENT-STATE.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
312 lines
10 KiB
JavaScript
312 lines
10 KiB
JavaScript
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
|
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
|
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
|
import { t } from '../i18n/index.js';
|
|
import { UiLifetime } from './uiLifetime.js';
|
|
|
|
/** Own initial share restoration, durable layer state and restoration notices. */
|
|
export class ShareRestoration {
|
|
constructor({
|
|
viewer,
|
|
navigation,
|
|
syncShareState,
|
|
syncModels3d,
|
|
showStatus,
|
|
feedback,
|
|
updateFeedback,
|
|
}) {
|
|
Object.assign(this, {
|
|
viewer,
|
|
navigation,
|
|
syncShareState,
|
|
syncModels3d,
|
|
showStatus,
|
|
feedback,
|
|
updateFeedback,
|
|
});
|
|
this._lifetime = new UiLifetime();
|
|
this._disposed = false;
|
|
this._shareTrackingAcquiringKey = null;
|
|
this._shareTrackingNoticeGeneration = 0;
|
|
this._initialShareState = null;
|
|
this._initialShareNavigationGeneration = null;
|
|
this._initialShareRestoreTimeout = null;
|
|
this._layerStateCoordinator = null;
|
|
this._layerStateRestorePromise = null;
|
|
}
|
|
attachLinks(shareLinkManager) {
|
|
this.shareLinkManager = shareLinkManager;
|
|
this._initialShareState = shareLinkManager.parseInitialHash();
|
|
}
|
|
start() {
|
|
// Restore from URL hash if present
|
|
const savedState = this._initialShareState;
|
|
this._initialShareRestorePromise = savedState
|
|
? new Promise((resolve) => {
|
|
this._resolveInitialShareRestore = resolve;
|
|
})
|
|
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
|
|
if (savedState) {
|
|
this._hasShareState = true;
|
|
// Reserve camera authority now; the delayed mesh-friendly flight may
|
|
// run only if no newer user, voice, or tracking navigation has won.
|
|
this._initialShareNavigationGeneration =
|
|
this.navigation._beginDeferredNavigation('shared view', {
|
|
cancelPendingSelection: false,
|
|
});
|
|
this._initialShareRestoreTimeout = setTimeout(() => {
|
|
this._initialShareRestoreTimeout = null;
|
|
if (this._disposed) return;
|
|
const generation = this._initialShareNavigationGeneration;
|
|
const applyCamera =
|
|
Number.isInteger(generation) &&
|
|
this.navigation._reassertNavigationHandoff(generation);
|
|
void (async () => {
|
|
try {
|
|
const share = await this.shareLinkManager.applyState(savedState, {
|
|
applyCamera,
|
|
navigationToken: generation,
|
|
});
|
|
const layers = await (this._layerStateRestorePromise ||
|
|
Promise.resolve([]));
|
|
const tracking =
|
|
share.camera === 'applied'
|
|
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
|
: {
|
|
status: 'superseded',
|
|
cleared:
|
|
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
|
'shared-camera-superseded',
|
|
{ clearSelection: true },
|
|
) === true,
|
|
};
|
|
this.shareLinkManager.completeInitialRestore();
|
|
this._settleInitialShareRestore({
|
|
status: 'settled',
|
|
share,
|
|
layers,
|
|
tracking,
|
|
});
|
|
} catch (error) {
|
|
this.shareLinkManager.completeInitialRestore();
|
|
this._settleInitialShareRestore({
|
|
status: 'failed',
|
|
error: String(error?.message || error),
|
|
share: null,
|
|
layers: [],
|
|
});
|
|
}
|
|
})();
|
|
}, 1500);
|
|
} else {
|
|
this.syncShareState();
|
|
}
|
|
// A recipient can orbit before or during the delayed share flight. That
|
|
// gesture keeps ordinary layer state but revokes the passive base camera
|
|
// and selected-subject Follow so delayed work cannot seize navigation.
|
|
this._initialShareGestureHandler = () => {
|
|
if (
|
|
this._disposed ||
|
|
!this._hasShareState ||
|
|
!this._resolveInitialShareRestore
|
|
)
|
|
return;
|
|
stampInitialShareGesture((options) =>
|
|
this.navigation._stampNavigation(options),
|
|
);
|
|
};
|
|
this.viewer?.canvas?.addEventListener(
|
|
'pointerdown',
|
|
this._initialShareGestureHandler,
|
|
{
|
|
passive: true,
|
|
},
|
|
);
|
|
this.viewer?.canvas?.addEventListener(
|
|
'wheel',
|
|
this._initialShareGestureHandler,
|
|
{
|
|
passive: true,
|
|
},
|
|
);
|
|
}
|
|
connect(dataManager) {
|
|
this._dataManager = dataManager;
|
|
this._layerStateCoordinator?.destroy();
|
|
this._layerStateCoordinator = null;
|
|
this._layerStateRestorePromise = null;
|
|
if (this._dataManager) {
|
|
this._layerStateCoordinator = new LayerStateCoordinator(
|
|
this._dataManager,
|
|
this.shareLinkManager,
|
|
{
|
|
onDurableStateChange: (state) => this.syncModels3d(state),
|
|
onTrackingRestoreStatus: (result) =>
|
|
this._handleShareTrackingRestoreStatus(result),
|
|
},
|
|
);
|
|
this._layerStateRestorePromise = this._layerStateCoordinator.start({
|
|
shareLayerState: this._initialShareState?.layerState || null,
|
|
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
|
|
// Any valid camera/style share isolates recipient-local preferences,
|
|
// including legacy and malformed-v2 layer payloads.
|
|
allowLocalState: !this._initialShareState,
|
|
});
|
|
if (this._initialShareSelectionSuperseded) {
|
|
this._layerStateCoordinator.cancelPendingShareTracking(
|
|
'superseded-before-layer-coordinator-start',
|
|
{ clearSelection: true },
|
|
);
|
|
}
|
|
void this._layerStateRestorePromise.then(() => {
|
|
this.syncModels3d(this._layerStateCoordinator?.getDurableState());
|
|
});
|
|
}
|
|
}
|
|
cancelSelection() {
|
|
if (
|
|
this._hasShareState &&
|
|
this._resolveInitialShareRestore &&
|
|
!this._layerStateCoordinator
|
|
)
|
|
this._initialShareSelectionSuperseded = true;
|
|
return (
|
|
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
|
'superseded-by-explicit-navigation',
|
|
{ clearSelection: true },
|
|
) === true
|
|
);
|
|
}
|
|
get initialRestorePromise() {
|
|
return (
|
|
this._initialShareRestorePromise ||
|
|
Promise.resolve({ status: 'not-requested' })
|
|
);
|
|
}
|
|
_handleShareTrackingRestoreStatus(result) {
|
|
if (!result || this._disposed) return;
|
|
const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`;
|
|
if (result.classification === 'pending') {
|
|
this._shareTrackingNoticeGeneration += 1;
|
|
this._shareTrackingAcquiringKey = trackingKey;
|
|
this.showStatus(t('ACQUIRING'), {
|
|
state: 'acquiring',
|
|
detail: t('SHARED {subject}', {
|
|
subject: String(result.label || t('entity')).toUpperCase(),
|
|
}),
|
|
persistent: true,
|
|
});
|
|
return;
|
|
}
|
|
const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey;
|
|
if (ownsAcquiringNotice) {
|
|
this._shareTrackingNoticeGeneration += 1;
|
|
this._shareTrackingAcquiringKey = null;
|
|
if (this.feedback._globalStatusNotice?.state === 'acquiring') {
|
|
this.feedback._globalStatusNotice = null;
|
|
this.updateFeedback();
|
|
}
|
|
}
|
|
if (
|
|
result.classification === 'followed' ||
|
|
result.classification === 'cancelled'
|
|
)
|
|
return;
|
|
// A stale terminal result must never replace a newer target's acquisition.
|
|
if (this._shareTrackingAcquiringKey) return;
|
|
const noticeGeneration = ownsAcquiringNotice
|
|
? this._shareTrackingNoticeGeneration
|
|
: ++this._shareTrackingNoticeGeneration;
|
|
const subject = result.label || t('entity');
|
|
const message =
|
|
result.classification === 'expired'
|
|
? t('Shared {subject} follow expired', { subject })
|
|
: result.classification === 'source-unavailable'
|
|
? t('Shared {subject} could not be restored — feed unavailable', {
|
|
subject,
|
|
})
|
|
: t('Shared {subject} is unavailable', { subject });
|
|
const showAfterStartupCover = () => {
|
|
this._lifetime.frame(() => {
|
|
if (
|
|
!canPresentDeferredStatusNotice(
|
|
noticeGeneration,
|
|
this._shareTrackingNoticeGeneration,
|
|
this._disposed,
|
|
)
|
|
)
|
|
return;
|
|
const startupCover = document.getElementById('loading-screen');
|
|
if (
|
|
!startupCover ||
|
|
getComputedStyle(startupCover).visibility === 'hidden'
|
|
) {
|
|
this.showStatus(message);
|
|
return;
|
|
}
|
|
let fallbackTimer = null;
|
|
let removeStartupListener = () => {};
|
|
const showOnce = () => {
|
|
removeStartupListener();
|
|
if (fallbackTimer) this._lifetime.cancelTimeout(fallbackTimer);
|
|
if (
|
|
canPresentDeferredStatusNotice(
|
|
noticeGeneration,
|
|
this._shareTrackingNoticeGeneration,
|
|
this._disposed,
|
|
)
|
|
)
|
|
this.showStatus(message);
|
|
};
|
|
removeStartupListener = this._lifetime.listen(
|
|
startupCover,
|
|
'transitionend',
|
|
showOnce,
|
|
{ once: true },
|
|
);
|
|
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
|
|
});
|
|
};
|
|
if (this._resolveInitialShareRestore) {
|
|
void this.initialRestorePromise.then(showAfterStartupCover);
|
|
return;
|
|
}
|
|
showAfterStartupCover();
|
|
}
|
|
_settleInitialShareRestore(result) {
|
|
if (!this._resolveInitialShareRestore) return;
|
|
const resolve = this._resolveInitialShareRestore;
|
|
this._resolveInitialShareRestore = null;
|
|
resolve(result);
|
|
window.dispatchEvent(
|
|
new CustomEvent('gev:initial-share-restore-settled', { detail: result }),
|
|
);
|
|
}
|
|
destroy() {
|
|
if (this._disposed) return;
|
|
this._disposed = true;
|
|
this._shareTrackingNoticeGeneration += 1;
|
|
this._shareTrackingAcquiringKey = null;
|
|
this._layerStateCoordinator?.destroy();
|
|
this._layerStateCoordinator = null;
|
|
this._layerStateRestorePromise = null;
|
|
clearTimeout(this._initialShareRestoreTimeout);
|
|
this._initialShareRestoreTimeout = null;
|
|
this._settleInitialShareRestore({
|
|
status: 'destroyed',
|
|
share: null,
|
|
layers: [],
|
|
});
|
|
this.viewer?.canvas?.removeEventListener(
|
|
'pointerdown',
|
|
this._initialShareGestureHandler,
|
|
);
|
|
this.viewer?.canvas?.removeEventListener(
|
|
'wheel',
|
|
this._initialShareGestureHandler,
|
|
);
|
|
this._initialShareGestureHandler = null;
|
|
this._lifetime.destroy();
|
|
}
|
|
}
|