Реактивность и состояние

Разработка интерактивных картографических приложений практически всегда связана с управлением состоянием. Пользователь перемещает карту, изменяет масштаб, выбирает объекты, выполняет поиск адресов, строит маршруты и взаимодействует с различными слоями данных. Каждое подобное действие изменяет текущее состояние приложения и требует обновления пользовательского интерфейса.

В экосистеме HERE Maps API реактивность строится вокруг событий, объектов карты и внешних механизмов управления состоянием. Несмотря на то что сама библиотека не предоставляет встроенную реактивную модель наподобие React, Vue или Svelte, её архитектура позволяет эффективно интегрировать любые современные подходы к управлению данными.


Понятие состояния в картографическом приложении

Состояние представляет собой набор данных, описывающих текущее положение приложения.

Типичное состояние карты включает:

  • текущий центр карты;
  • уровень масштабирования;
  • выбранный объект;
  • список отображаемых маркеров;
  • активные маршруты;
  • состояние слоев;
  • результаты поиска;
  • пользовательские настройки отображения.

Пример структуры состояния:

const state = {
    center: {
        lat: 50.4501,
        lng: 30.5234
    },
    zoom: 12,
    selectedMarkerId: null,
    markers: [],
    route: null,
    trafficEnabled: false
};

Все визуальные элементы карты должны отражать актуальное состояние объекта state.


Источники изменений состояния

В HERE Maps состояние может изменяться из нескольких источников одновременно.

Действия пользователя

Например:

  • перемещение карты;
  • масштабирование;
  • клик по объекту;
  • выделение маршрута.
map.addEventListener('tap', event => {
    console.log('Карта нажата');
});

Получение данных от сервера

После выполнения запроса могут появиться новые маркеры:

fetch('/api/places')
    .then(response => response.json())
    .then(places => {
        state.markers = places;
    });

Автоматические обновления

Например, получение координат транспортного средства каждые несколько секунд:

setInterval(updateVehiclePosition, 5000);

Реактивная модель обновления

Без реактивности изменение данных не влияет автоматически на отображение.

Пример:

state.zoom = 15;

Карта не изменится сама по себе.

Необходимо явно синхронизировать состояние и представление:

map.setZoom(state.zoom);

Более удобным подходом является централизованное обновление интерфейса через функцию рендеринга.

function render() {
    map.setCenter(state.center);
    map.setZoom(state.zoom);
}

После изменения состояния:

state.zoom = 15;

render();

Хранение экземпляра карты в состоянии

Во многих проектах объект карты хранится отдельно от бизнес-данных.

Распространённая структура:

const app = {
    map: null,
    ui: null,
    beh * avior: null
};

Инициализация:

app.map = new H.Map(
    container,
    defaultLayers.vector.normal.map,
    {
        zoom: 10
    }
);

Такой подход позволяет отделить данные приложения от технических объектов HERE Maps.


События как основа реактивности

Большинство реактивных сценариев строится вокруг событий.

Изменение центра карты

map.addEventListener('mapviewchangeend', () => {
    const center = map.getCenter();

    state.center = {
        lat: center.lat,
        lng: center.lng
    };
});

Теперь состояние всегда содержит актуальные координаты.


Изменение масштаба

map.addEventListener('mapviewchangeend', () => {
    state.zoom = map.getZoom();
});

Получается двусторонняя связь:

  • пользователь меняет карту;
  • состояние обновляется;
  • интерфейс может реагировать на изменение состояния.

Реактивное управление маркерами

Количество маркеров часто зависит от данных приложения.

Простая реализация

function renderMarkers() {
    map.removeObjects(map.getObjects());

    state.markers.forEach(item => {
        const marker = new H.map.Marker({
            lat: item.lat,
            lng: item.lng
        });

        map.addObject(marker);
    });
}

Изменение данных:

state.markers.push({
    lat: 50.46,
    lng: 30.51
});

renderMarkers();

Дифференциальное обновление

Полное удаление всех объектов может быть дорогой операцией.

Более эффективный подход — обновлять только изменившиеся элементы.

Создание словаря объектов:

const markerStore = new Map();

Добавление:

function addMarker(place) {
    const marker = new H.map.Marker({
        lat: place.lat,
        lng: place.lng
    });

    markerStore.set(place.id, marker);

    map.addObject(marker);
}

Удаление:

function removeMarker(id) {
    const marker = markerStore.get(id);

    if (marker) {
        map.removeObject(marker);
        markerStore.delete(id);
    }
}

Подобная архитектура хорошо масштабируется для тысяч объектов.


Реактивное выделение объектов

Состояние может хранить выбранный объект.

state.selectedMarkerId = null;

Обработчик выбора:

marker.addEventListener('tap', () => {
    state.selectedMarkerId = place.id;

    renderSelection();
});

Обновление интерфейса:

function renderSelection() {
    console.log(
        'Выбран объект',
        state.selectedMarkerId
    );
}

Работа с пользовательскими событиями

События HERE Maps позволяют формировать полноценную реактивную систему.

Например:

marker.addEventListener('tap', event => {
    const position = event.target.getGeometry();

    state.selectedLocation = position;
});

Теперь любое другое представление может использовать это состояние.


Наблюдение за изменениями состояния

Один из популярных подходов — создание простейшего хранилища.

const listeners = [];

Регистрация:

function subscribe(callback) {
    listeners.push(callback);
}

Уведомление:

function notify() {
    listeners.forEach(listener => listener(state));
}

Обновление состояния:

function setState(partialState) {
    Object.assign(state, partialState);

    notify();
}

Использование:

subscribe(currentState => {
    console.log(currentState);
});

Изменение:

setState({
    zoom: 14
});

Реактивность через Proxy

Современный JavaScript предоставляет объект Proxy.

Он позволяет отслеживать изменения автоматически.

const state = new Proxy(
    {
        zoom: 10
    },
    {
        set(target, property, value) {
            target[property] = value;

            render();

            return true;
        }
    }
);

Изменение:

state.zoom = 15;

Автоматически вызовет:

render();

Синхронизация состояния и карты

Необходимо избегать ситуаций, когда карта и состояние начинают расходиться.

Нежелательный вариант:

map.setZoom(14);

Состояние осталось прежним.

Лучше использовать единый источник истины.

state.zoom = 14;

render();

Внутри рендера:

function render() {
    map.setZoom(state.zoom);
}

Такое решение облегчает сопровождение больших проектов.


Интеграция с React

В React состояние обычно хранится через useState.

const [center, setCenter] = useState({
    lat: 50.45,
    lng: 30.52
});

Событие карты:

map.addEventListener('mapviewchangeend', () => {
    const position = map.getCenter();

    setCenter({
        lat: position.lat,
        lng: position.lng
    });
});

Реакция на изменение:

useEffect(() => {
    map.setCenter(center);
}, [center]);

Получается двусторонняя синхронизация между React и HERE Maps.


Интеграция с Vue

Во Vue состояние является реактивным по умолчанию.

const state = reactive({
    zoom: 10
});

Наблюдение:

watch(
    () => state.zoom,
    value => {
        map.setZoom(value);
    }
);

Изменение масштаба:

state.zoom = 15;

Карта обновится автоматически.


Интеграция с Svelte

В Svelte часто используются Stores.

import { writable } from 'svelte/store';

export const zoom = writable(10);

Подписка:

zoom.subscribe(value => {
    map.setZoom(value);
});

Изменение:

zoom.set(14);

Работа с маршрутами как состоянием

Маршрут также удобно хранить в централизованном состоянии.

state.route = null;

После получения маршрута:

router.calculateRoute(
    params,
    result => {
        state.route = result.routes[0];

        renderRoute();
    }
);

Отрисовка:

function renderRoute() {
    if (!state.route) {
        return;
    }

    const lineString =
        H.geo.LineString.fromFlexiblePolyline(
            state.route.sections[0].polyline
        );

    const polyline =
        new H.map.Polyline(lineString);

    map.addObject(polyline);
}

Состояние пользовательского интерфейса

Не только карта обладает состоянием.

Например:

state.sidebarOpened = true;

state.selectedTab = 'traffic';

state.searchText = '';

Все элементы интерфейса могут синхронизироваться через единое хранилище.


Асинхронное состояние

Поиск адресов выполняется асинхронно.

Полезно хранить дополнительные признаки.

state.search = {
    loading: false,
    results: [],
    error: null
};

Начало запроса:

state.search.loading = true;

Успешное завершение:

state.search.results = data.items;

state.search.loading = false;

Ошибка:

state.search.error = error.message;

state.search.loading = false;

Такой подход делает интерфейс предсказуемым.


Управление состоянием слоев

Пример хранения активных слоев:

state.layers = {
    traffic: false,
    incidents: false,
    satellite: false
};

Переключение:

state.layers.traffic =
    !state.layers.traffic;

Реакция:

function updateLayers() {
    if (state.layers.traffic) {
        map.addLayer(trafficLayer);
    }
}

Централизованное хранилище приложения

В крупных проектах применяется единый Store.

const store = {
    state: {
        center: null,
        zoom: 10,
        markers: [],
        route: null
    },

    subscribers: []
};

Подписка:

store.subscribers.push(render);

Обновление:

function update(data) {
    Object.assign(store.state, data);

    store.subscribers.forEach(
        callback => callback()
    );
}

Использование:

update({
    zoom: 16
});

Избежание циклических обновлений

Частая проблема реактивных систем — бесконечный цикл обновлений.

Пример ошибки:

map.addEventListener(
    'mapviewchangeend',
    () => {
        state.zoom = map.getZoom();

        render();
    }
);

Внутри рендера:

function render() {
    map.setZoom(state.zoom);
}

Если изменение масштаба снова вызывает событие, возникает цикл.

Обычно применяют проверку изменений.

function render() {
    if (map.getZoom() !== state.zoom) {
        map.setZoom(state.zoom);
    }
}

Производительность реактивных обновлений

При работе с большим количеством данных необходимо минимизировать количество операций.

Основные рекомендации:

  • обновлять только изменившиеся объекты;
  • избегать полной перерисовки карты;
  • использовать группировку маркеров;
  • применять кэширование объектов;
  • ограничивать частоту обработчиков событий;
  • использовать debounce и throttle для поисковых запросов.

Пример debounce:

function debounce(callback, delay) {
    let timer;

    return (...args) => {
        clearTimeout(timer);

        timer = setTimeout(
            () => callback(...args),
            delay
        );
    };
}

Использование:

const search =
    debounce(runSearch, 500);

Архитектура однонаправленного потока данных

Наиболее устойчивой моделью считается схема:

Событие
    ↓
Изменение состояния
    ↓
Рендеринг
    ↓
Обновление карты

Пример:

marker.addEventListener('tap', () => {
    setState({
        selectedMarkerId: marker.id
    });
});

После изменения:

render();

Во время рендеринга:

updateSelection();
updateSidebar();
updateMap();

Подобная архитектура обеспечивает предсказуемость поведения приложения, упрощает отладку и позволяет масштабировать проекты на HERE Maps API без возникновения сложных зависимостей между компонентами интерфейса и картографическими объектами.