Files
gods-eye-view/src/ui/shareRestoration.js
T
German KhokhlovandClaude Opus 5.5 13efd411b6 refactor(i18n): key messages by English text; add Russian
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]>
2026-10-12 13:48:56 +04:00

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();
}
}