Камера в URL: синхронизация, восстановление и конвертация между 2D и 3D

Состояние камеры в URL: как это работает изнутри
Карта авиамаршрутов поддерживает два режима отображения — плоскую 2D-карту на проекции Миллера и 3D-глобус на Three.js. Каждый режим управляется своей камерой с принципиально разными системами координат. Захотелось сделать так, чтобы текущий вид карты сохранялся в URL: и чтобы ссылку можно было передать, и чтобы страница восстанавливалась именно с той позицией, с которой её закрыли.
На первый взгляд — просто читаем координаты камеры, пишем в query-параметры, при загрузке читаем обратно. На практике возникло несколько проблем: гонка с инициализацией Three.js, несовместимые системы координат двух режимов и вопрос о том, что показывать при первом переключении между режимами, если камера там ещё ни разу не была.
Что пишется в URL
В 2D-режиме записываются три параметра — позиция камеры в пространстве плоской проекции и зум:
?x=0.2031&y=0.3418&z=15.6102
В 3D — три компоненты позиции камеры в трёхмерном пространстве:
?cx=-0.1993&cy=1.2334&cz=-0.9859
Параметры режима, авиакомпании и аэропорта живут в path:
/3d/S7/IKT?cx=-0.1993&cy=1.2334&cz=-0.9859
Обновление URL разделено на два потока. Режим, авиакомпания и аэропорт пишутся сразу — они меняются дискретно по клику. Координаты камеры обновляются с задержкой 400 мс после последнего события от карты, чтобы не заваливать историю браузера каждым кадром анимации.
typescript
const scheduleCameraUrlUpdate = () => {
if (cameraUrlTimer !== null) clearTimeout(cameraUrlTimer);
cameraUrlTimer = setTimeout(() => replaceUrl(), 400);
};
Восстановление при открытии ссылки
Первая нетривиальная проблема — тайминг инициализации. Three.js-камера создаётся внутри onMounted компонента. OrbitControls сразу вызывает controls.update(), что генерирует событие change — и уже в первом кадре координаты камеры по умолчанию перезаписывали параметры из URL.
Решение — флаг isApplyingViewFromRoute, который выставляется в true в самом начале onMounted и снимается только после того, как параметры из URL применены к камере. Пока флаг активен, все события view-change от карт игнорируются и URL не обновляется.
Вторая проблема — Vue-вотчер на route.query срабатывает до обновления DOM, то есть до монтирования компонента карты и инициализации камеры внутри него. Вызов setViewState в этот момент — no-op.
Решение — передавать начальное состояние через проп initialView. Компонент карты применяет его в своём onMounted после init(), когда камера уже гарантированно существует:
typescript
onMounted(async () => {
init(canvasRef.value);
if (props.initialView) {
setViewState(props.initialView);
disableAutoFitForAirline(selectedAirline.value);
}
draw();
});
Значение initialView вычисляется заранее — к моменту монтирования компонента параметры из URL уже разобраны и лежат в сторе.
Конвертация координат между режимами
При переключении 2D → 3D и обратно камера должна смотреть на ту же географическую область с примерно тем же масштабом.
Системы координат принципиально разные. В 2D — проекция Миллера, позиция камеры это плоские координаты (x, y) плюс зум. В 3D — перспективная камера на глобусе, позиция это вектор (cx, cy, cz) в трёхмерном пространстве.
Глобус использует нестандартную систему координат — тут логика конвертации более хитрая:
x = R · cos(lat) · cos(lon)
y = R · sin(lat)
z = −R · cos(lat) · sin(lon)
Масштаб согласуется через условие равенства видимой высоты в центре обоих режимов:
π · cos(0.8 · lat) / zoom = (d − R) · tan(FOV/2)
Из этого уравнения выводится прямое преобразование зума в дистанцию камеры и обратно. Конвертация направления взгляда — через обратную проекцию Миллера и пересечение луча camera→target с поверхностью глобуса.
Полная логика конвертации вынесена в src/utils/camera-convert.ts:
typescript
export function convert2dTo3d(x, y, zoom): { x, y, z }
export function convert3dTo2d(cx, cy, cz): { x, y, zoom }
Пре-вычисление при загрузке ссылки
Если открыть ссылку с параметрами 2D-камеры, а затем переключиться в 3D, нужно показать ту же область. Но к моменту переключения 3D-позиция ещё не известна — камера в этом режиме ни разу не использовалась.
При разборе URL сразу вычисляются и сохраняются координаты для обоих режимов. Как только 2D-параметры применены, конвертация в 3D происходит немедленно, до любого взаимодействия пользователя:
typescript
const applyFrom2dQuery = (query): boolean => {
// применяем 2D-параметры из URL...
// сразу пре-вычисляем 3D
if (ax !== null && ay !== null && az !== null) {
const c = convert2dTo3d(ax, ay, az);
view3dX.value = c.x;
view3dY.value = c.y;
view3dZ.value = c.z;
}
return true;
};
Аналогично для ссылки с 3D-параметрами: 2D-координаты пересчитываются заранее, и при переключении карта сразу открывается на нужной области.
Camera store
Всё состояние камеры и операции над ним собраны в src/stores/camera.store.ts.
Стор хранит шесть реактивных значений: view2dX/Y/Zoom и view3dX/Y/Z. Два computed — view2d и view3d — собирают их в объекты для передачи в :initial-view пропы компонентов карты; оба возвращают null, если ни одна из координат ещё не задана.
Методы стора:
applyFrom2dQuery(query)/applyFrom3dQuery(query)— парсинг URL и пре-вычисление координат второго режимаsetFrom2dChange(v)/setFrom3dChange(v)— обновление по событиям от картыsyncFrom2dTo3d()/syncFrom3dTo2d()— конвертация при переключении режима пользователемbuildQuery2d()/buildQuery3d()— формирование query-параметров для URL
Благодаря стору index.vue перестал содержать логику камеры — там остались только оркестрация маршрутизатора и связь с компонентами карты через ref.