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

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

Состояние камеры в URL: как это работает изнутри

Карта авиамаршрутов поддерживает два режима отображения — плоскую 2D-карту на проекции Миллера и 3D-глобус на Three.js. Каждый режим управляется своей камерой с принципиально разными системами координат. Захотелось сделать так, чтобы текущий вид карты сохранялся в URL: и чтобы ссылку можно было передать, и чтобы страница восстанавливалась именно с той позицией, с которой её закрыли.

На первый взгляд — просто читаем координаты камеры, пишем в query-параметры, при загрузке читаем обратно. На практике возникло несколько проблем: гонка с инициализацией Three.js, несовместимые системы координат двух режимов и вопрос о том, что показывать при первом переключении между режимами, если камера там ещё ни разу не была.

Что пишется в URL

В 2D-режиме записываются три параметра — позиция камеры в пространстве плоской проекции и зум:

Copy
?x=0.2031&y=0.3418&z=15.6102

В 3D — три компоненты позиции камеры в трёхмерном пространстве:

Copy
?cx=-0.1993&cy=1.2334&cz=-0.9859

Параметры режима, авиакомпании и аэропорта живут в path:

Copy
/3d/S7/IKT?cx=-0.1993&cy=1.2334&cz=-0.9859

Обновление URL разделено на два потока. Режим, авиакомпания и аэропорт пишутся сразу — они меняются дискретно по клику. Координаты камеры обновляются с задержкой 400 мс после последнего события от карты, чтобы не заваливать историю браузера каждым кадром анимации.

typescript Copy
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 Copy
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) в трёхмерном пространстве.

Глобус использует нестандартную систему координат — тут логика конвертации более хитрая:

Copy
x = R · cos(lat) · cos(lon)
y = R · sin(lat)
z = −R · cos(lat) · sin(lon)

Масштаб согласуется через условие равенства видимой высоты в центре обоих режимов:

Copy
π · cos(0.8 · lat) / zoom  =  (d − R) · tan(FOV/2)

Из этого уравнения выводится прямое преобразование зума в дистанцию камеры и обратно. Конвертация направления взгляда — через обратную проекцию Миллера и пересечение луча camera→target с поверхностью глобуса.

Полная логика конвертации вынесена в src/utils/camera-convert.ts:

typescript Copy
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 Copy
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.

Читать далее

24.03.2026

Stronghold: генератор паролей прямо в браузере

На toolz.macrulez.ru появился новый модуль — Stronghold. Это генератор криптографически стойких паролей с полным контролем над набором символов, длиной, количеством вариантов и дополнительными ограничениями. Все операции выполняются в браузере — никакие данные не покидают устройство. Поддерживает латиницу, кириллицу и спецсимволы, показывает оценку надёжности в битах энтропии и позволяет сохранить результат в текстовый файл.

Метки
паролибезопасностьгенераторвебмастерtoolz
28.03.2026

rest-pipeline-js 1.3.0: параллельность, middleware, пауза и экспорт состояния

Крупное обновление библиотеки для оркестрации REST API запросов. Параллельные шаги, глобальный middleware, pause/resume, экспорт и восстановление состояния — и заодно закрыт ряд неприятных багов, которые тихо жили в коде с самого начала.

Метки
javascripttypescriptrest-apipipelineopen-source
28.03.2026

responsive-media 1.1 — реактивные брейкпоинты, container queries и полный рефакторинг

Вышла новая версия библиотеки responsive-media. Полный рефакторинг на абстрактный базовый класс, container queries через ResizeObserver, богатый subscription API, хелперы для упорядоченных брейкпоинтов, готовые пресеты Tailwind / Bootstrap / Accessibility, интеграция с React 18+, синхронизация CSS-переменных, DOM-события и многое другое.

Метки
responsive-mediaTypeScriptVue 3Reactmedia queriesадаптивная вёрстка