Переход на модели Airport/Company и кеширование данных на клиенте

23.03.2026
Переход на модели Airport/Company и кеширование данных на клиенте

Интерактивная карта маршрутных сетей росла постепенно: сначала прямые ответы API, потом больше экранов, модалка каталога, доп. карточки аэропортов, автокомплит, фильтры и отдельные сценарии навигации.
В какой-то момент стало очевидно: держать всё на «сырых» объектах ответа уже неудобно. Слишком много условных полей, разный формат данных в разных эндпоинтах и дублирование логики по всему фронту.

Поэтому я сделал два шага:

  1. Перевёл слой данных на модели Airport и Company
  2. Добавил клиентское кеширование загруженных сущностей, чтобы не дергать API повторно без необходимости

Ниже — что это дало на практике.


Почему вообще понадобились модели

До перехода логика выглядела примерно так: «взяли ответ API, где-то в компоненте мапнули, где-то проверили null, где-то построили label».
Работает, но со временем появляется эффект «расползания правил»:

  • в одном месте имя аэропорта берётся из municipality, в другом — из name;
  • где-то IATA обязательный, где-то нет;
  • агрегаты авиакомпании (routes_count, airports_count) хранятся рядом с UI-логикой;
  • подсветка маршрутов и селектор знают про структуру ответа API больше, чем им нужно.

Модели решают это централизованно: нормализация в одном месте, интерфейсы полей в одном месте, бизнес-методы рядом с данными.


Airport: единая точка правды по аэропортам

Файл: src/models/airport.model.ts

Что вынесено в модель:

  • структура API-ответа (AirportApiResponse);
  • нормализация в fromApiResponse() (валидный IATA, координаты, country, translations);
  • отдельный конструктор для компактных данных карты (fromMapData());
  • дозагрузка/обогащение уже существующего объекта через enrichFromApi();
  • признак полноты данных isEnriched и проверка координат hasCoords().

Ключевая идея — один и тот же Airport может сначала прийти «лёгким» (для быстрого рендера), а потом стать «полным» после запроса деталей.
Это особенно полезно для сценария карточки аэропорта: мы не создаём второй объект и не дублируем сущность, а дообогащаем уже кешированный экземпляр.

Пример: создание модели из полного ответа API

ts Copy
static fromApiResponse(raw: AirportApiResponse): Airport | null {
  const iata = raw.iata_code;
  if (!iata) return null;

  const a = new Airport(iata, raw.municipality || raw.name || iata);
  a.icao = raw.icao_code ?? null;
  a.lat = raw.latitude_deg ?? null;
  a.lon = raw.longitude_deg ?? null;
  a.municipality = raw.municipality ?? null;
  a.countryName = raw.country_name ?? null;
  a.translations = raw.translations ?? null;
  a.airlinesCount = raw.airlines_count ?? null;
  a.isEnriched = true;
  return a;
}

Пример: обогащение уже закешированного аэропорта

ts Copy
enrichFromApi(raw: AirportApiResponse): void {
  if (raw.icao_code) this.icao = raw.icao_code;
  if (raw.latitude_deg != null) this.lat = raw.latitude_deg;
  if (raw.longitude_deg != null) this.lon = raw.longitude_deg;
  if (raw.municipality) this.municipality = raw.municipality;
  if (raw.country_name) this.countryName = raw.country_name;
  if (raw.translations) this.translations = raw.translations;
  if (raw.airlines_count != null) this.airlinesCount = raw.airlines_count;
  this.isEnriched = true;
}

Company: модель авиакомпании + сервисные методы

Файл: src/models/company.model.ts

Что даёт Company:

  • типобезопасный разбор ответа API в fromApiResponse();
  • хранение имени сразу в двух языках (name.en / name.ru);
  • готовый формат для селектора (getSelectorLabel());
  • единый метод отображения названия с fallback (getDisplayName());
  • вычисление графа связей:
    • getConnections() — уникальные пары dep↔arr;
    • getDirectedRoutes() — направленные маршруты dep|arr.

В итоге компонентам карты не нужно знать, как именно хранится «сырая» маршрутная сеть.
Они работают с готовой моделью, у которой уже есть нужные представления данных.

Пример: подпись в селекторе из модели

ts Copy
getSelectorLabel(): string {
  const parts: string[] = [];
  if (this.name.ru) parts.push(this.name.ru);
  if (this.name.en) parts.push(this.name.en);
  parts.push(this.iata);
  if (this.icao) parts.push(this.icao);
  return parts.join(' | ');
}

Пример: вычисление уникальных связей для визуализации

ts Copy
getConnections(): Array<{ dep: string; arr: string }> {
  const seen = new Set<string>();
  const result: Array<{ dep: string; arr: string }> = [];
  for (const p of this.points) {
    for (const arr of p.arrival) {
      if (!arr) continue;
      const key = p.departure < arr ? `${p.departure}|${arr}` : `${arr}|${p.departure}`;
      if (!seen.has(key)) {
        seen.add(key);
        result.push({ dep: p.departure, arr });
      }
    }
  }
  return result;
}

Кеширование на клиенте: что именно кешируется

Основная реализация в src/stores/map.store.ts.

1) Кеш аэропортов по IATA

В сторе есть долгоживущий словарь:

  • airportCache: Record<string, Airport>

Это даёт:

  • мгновенный повторный доступ к координатам/названию аэропорта;
  • отсутствие повторных запросов для уже встречавшихся dep_iata / arr_iata;
  • возможность дозагружать детали только когда реально нужно.

Базовая схема в сторе выглядит так:

ts Copy
const airports = ref<Record<string, Airport>>({});
const airportCache: Record<string, Airport> = {};

Все новые аэропорты (из первичной загрузки или из маршрутов) сначала попадают в airportCache,
а затем одним присваиванием обновляют реактивное состояние:

ts Copy
airports.value = { ...airportCache };

2) Ленивое обогащение (lazy enrich)

Для карточки аэропорта используется паттерн:

  • если аэропорт в кеше есть, но isEnriched === false → делаем параллельно:
    • /airlines/airports/iata/:code?lang=ru
    • /airlines/routes/airport/:code
  • после ответа обновляем тот же объект Airport через enrichFromApi().

Если аэропорт уже обогащён — запрашиваются только маршруты, без повторной загрузки метаданных аэропорта.

Пример: lazy enrich через pipeline

ts Copy
if (!cached?.isEnriched) {
  const orchestrator = new PipelineOrchestrator({
    config: {
      stages: [
        { key: 'airport', request: () => `/airlines/airports/iata/${code}?lang=ru` },
        { key: 'routes', request: () => `/airlines/routes/airport/${code}` },
      ],
    },
    httpConfig: { baseURL: API_BASE_URL, timeout: 15000, retry: { attempts: 2, delayMs: 1000 } },
  });
  const result = await orchestrator.run();
  const rawAirport = result.stageResults?.airport?.data?.data;
  if (rawAirport && cached) cached.enrichFromApi(rawAirport);
}

3) Кеш после первичной инициализации

На старте приложения грузятся:

  • агрегированная статистика АК;
  • крупные и средние аэропорты РФ.

Данные сразу превращаются в модели и кладутся в стор/кеш.
Дальше при выборе авиакомпании догружаются только отсутствующие аэропорты.

Пример: первичный прогрев данных

ts Copy
const orchestrator = new PipelineOrchestrator({
  config: {
    stages: [
      { key: 'airlines', request: () => '/airlines/routes/stats/airlines' },
      { key: 'airports_large', request: () => '/airlines/airports?country=RU&type=large_airport&limit=200' },
      { key: 'airports_medium', request: () => '/airlines/airports?country=RU&type=medium_airport&limit=200' },
    ],
  },
  httpConfig: { baseURL: API_BASE_URL, timeout: 15000, retry: { attempts: 2, delayMs: 1000 } },
});

const result = await orchestrator.run();
airlines.value = (result.stageResults?.airlines?.data?.data ?? [])
  .map((a: any) => Company.fromApiResponse(a))
  .filter((c): c is Company => c !== null);

Мини-паттерн, который хорошо сработал

В итоге получился простой и рабочий цикл:

  1. Прогреваем «лёгкие» данные для быстрого старта UI
  2. Храним сущности в моделях, а не в сырых any
  3. Догружаем детали только по пользовательскому действию
  4. Обогащаем уже существующий объект в кеше, а не создаём дубликат
  5. Отдаём в UI только реактивный snapshot (airports.value = { ...airportCache })

Этот подход хорошо масштабируется:
когда добавляется новый экран (каталог, карточка, режим карты), он не ломает старые правила работы с данными, а переиспользует уже готовые модели и кеш.


Что это даёт пользователю

С пользовательской стороны эффект простой:

  • быстрее открывается карта после первых действий;
  • меньше «дёрганий» интерфейса при повторном выборе тех же аэропортов/АК;
  • меньше спиннеров в сценариях, где данные уже были загружены;
  • стабильнее логика отображения названий и подписей.

А с технической стороны — меньше дублирующей логики и проще поддержка новых фич (каталог, карточки, навигация по маршрутам, 2D/3D режимы).


Расширение кеша: каталог и версионирование

Этот шаг уже реализован в map.store: кеширование поднято на уровень запросов каталога.

Что кешируется в каталоге

Добавлены отдельные кеши:

  • catalogAirlineDetailsCache — раскрытые блоки маршрутной сети авиакомпании;
  • catalogAirportAirlinesCache — список авиакомпаний конкретного аэропорта;
  • catalogAirportLettersCache — список инициалов (букв) для вкладки аэропортов;
  • catalogAirportsByLetterCache — аэропорты для выбранной буквы.

Принцип ключей — version + payload, например:

  • ${version}|SU
  • ${version}|SVO
  • ${version}|airport_letter|A

Контролируемая инвалидиация

В сторе добавлен метод:

ts Copy
const invalidateCatalogCaches = () => {
  catalogAirlineDetailsCache.clear();
  catalogAirportAirlinesCache.clear();
  catalogAirportLettersCache.clear();
  catalogAirportsByLetterCache.clear();
};

Его можно вызывать вручную (например, после административной загрузки маршрутов),
а также он вызывается автоматически при смене версии данных.

Проверка версии данных

Для этого используется /airlines/routes/stats и поле newest_update.

ts Copy
const ensureCatalogCacheVersion = async (force = false) => {
  const now = Date.now();
  if (!force && now - lastCatalogVersionCheckMs < 60_000) return;

  const res = await apiClient.request('/airlines/routes/stats');
  const newestUpdate = (res.data as any)?.data?.newest_update;
  if (newestUpdate && catalogCacheVersion.value !== newestUpdate) {
    catalogCacheVersion.value = newestUpdate;
    invalidateCatalogCaches();
  }
};

Что это даёт:

  • при неизменных данных каталог работает почти полностью из памяти;
  • после обновления маршрутов старые ответы автоматически не используются;
  • не нужно руками «угадывать», когда кеш стал неактуальным.

Читать далее

24.03.2026

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

Реализовал запись положения камеры в URL, восстановление вида при открытии ссылки и конвертацию координат камеры между плоской картой и глобусом. Разобрал несколько нетривиальных проблем с таймингом инициализации и вынес всю логику в отдельный Pinia-стор.

Метки
three.jsvuetypescriptcamerarouting
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