В данной статье я опишу как можно перенести камеру так, чтобы поведение было идентичным с такими картографическими приложениями как 2ГИС. Приложения Яндекс.Карты и 2ГИС используют чуть упрощенный подход, здесь он тоже описан. Но об этом позже.

Pre-requirements

Flutter 3.35.6, Dart 3.9.2 иdgis_mobile_sdk_map: ^13.0.0

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

Тем не менее готовый код можно будет посмотреть тут.

Поставновка задачи

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

Важное условие задачи -- это должно работать и при повороте карты (bearing != 0).

То, где может оказаться маркер, при нажатии. Нужно обработать эту ситуацию и сдвинуть его в центр. У точки может быть 3 состояния: а) внутри сцены (зеленый), б) вне сцены, но внутри вьюпорта (желтый) и в) вне вьюпорта (красный)
То, где может оказаться маркер, при нажатии. Нужно обработать эту ситуацию и сдвинуть его в центр. У точки может быть 3 состояния: а) внутри сцены (зеленый), б) вне сцены, но внутри вьюпорта (желтый) и в) вне вьюпорта (красный)

То есть нам необходимо переместить точку на сцену. Для этого нам необходимо:

  • Узнать, в какой зоне находится точка

  • Переместить камеру при необходимости

У точки может быть 3 состояния: а) внутри сцены (зеленый), б) вне сцены, но внутри вьюпорта (желтый) и в) вне вьюпорта (красный)

  • Если точка находится внутри сцены, мы ничего не делаем.

  • Если точка находится во вне вьюпорта -- перемещаем точку на центр экрана (в качестве точки центра камеры выбираем координату маркера)

  • Иначе двигаем точку внутрь сцены на минимально необходимое расстояние

Таким образом вот наш алгоритм, по которому мы пойдем:

  1. Соберем все вводные: размер экрана; паддинги, которые формируют сцену; позицию камеры;

  2. Найдем координаты видимой области; проверим, что точка внутри этой области; если точка во вне этой зоны - перемещаем камеру на маркер и заканчиваем

  3. Иначе высчитаем координаты сцены; проверим, точка внутри сцены или нет; Если точка вне сцены, но внутри видимой области, переместим ее, при необходимости, по оси Oy и по оси Ox

  4. Иначе ничего не делаем, точка уже на сцене

1. Вводные

Здесь зеленым отмечена зона, в которой может располагаться маркер. Однако мы так же должны учесть размеры самого маркера
Здесь зеленым отмечена зона, в которой может располагаться маркер. Однако мы так же должны учесть размеры самого маркера

Мы должны заранее посчитать количество пикселей, которые занимают "рабочие" элементы по краям. Передать это можно объектом класса EdgeInsets

Так же мы передаем размер экрана через MediaQuery и позицию камеры через sdkMap.camera.position.point

2. Видимая область

Получение видимой области в 2ГИС MSDK достаточно простое:

final area = camera.visibleArea

Однако видимая область отдается точками с координамтами соответствующими углам экрана. Но если bearing != 0,то точки располагаются не в естественном порядке. Поэтому мы с помощью кастомной структурки CustomRect и метода fromArea преобразовываем точки из visibleArea в человекочитаемые и понятные для интерпретации и работы точки.

Далее, раз у нас есть координаты точек, мы можем посчитать, находится ли точка внутри этого прямоугольника. Вкратце, мы используем векторное произведение для понимания, с какой стороны находится наш маркер от каждой из сторон прямоугольника. Назовем этот метод _isInRectZone

Если этот метод возвращает false (то есть точка вне зоны), то мы перемещаем камеру на маркер в позицию markerPoint

if (!isInViewZone) {
    camera.moveToCameraPosition(
        newCameraPosition.copyWith(
            point: markerPoint, 
            zoom: Zoom(16),
        ),
    );
    return;
  }
Расчеты `_isInRectZone`

Метод _isInRectZone проверяет, лежит ли маркер P внутри повернутого прямоугольника сцены P1P2P3P4 (точки задаются по часовой стрелке).

1) Для каждой стороны считаются векторы

• сторона:\vec{v_i} = P_{i+1} - P_i

• к точке:\vec{u_i} = P - P_i

2) Вычисляется 2D векторное произведение:

\vec{v_i} \times \vec{u_i} = v_x \cdot u_y - v_y \cdot u_x

3) Точка внутри, если все произведения имеют одинаковый знак:

\text{sign}(c_1) + \text{sign}(c_2) + \text{sign}(c_3) + \text{sign}(c_4) =  4

В коде это сверяется выражением:

(cross1.sign + cross2.sign + cross3.sign + cross4.sign).abs() == 4

Такой тест опирается только на знаки и корректно работает при любом наклоне/повороте камеры, если вершины прямоугольника переданы в одном порядке (в CustomRect порядок фиксирован).

3. Сцена

Теперь посчитаем координаты сцены sceneRect. Для этого мы возьмем сцену в координатах [point1, point2, point3, point 4], возьмем размер экрана в пикселях, возьмем паддинг в пикселях и преобразуем это в проценты.

final paddingInPercents = EdgeInsets.only(
    left: paddingInPixels.left / sizeOfScreen.width,
    right: paddingInPixels.right / sizeOfScreen.width,
    top: paddingInPixels.top / sizeOfScreen.height,
    bottom: paddingInPixels.bottom / sizeOfScreen.height,
  );

Затем проценты умножим на координаты, получим координаты новой области. Сами расчеты чуть сложнее, я их делал через вектора, но суть понятна: переводим пиксели в координаты.

Чуть духоты: мы можем использовать формулы для уточнения расстояния между двумя координатами, учитывая радиус Земли. Но для больших зумов это пренебрежимо мало, а на больших зумах можно либо не ставить точку, либо перемещать ее всегда в центр экрана. Однако для большей точности на малых зумах это можно было бы использовать.

Как только мы получили sceneRect, помещаем его в _isInRectZone. Если точка вне нашей сцены, то двигаем точку с помощью метода _movePoint

При перемещении точки я придумал такой алгоритм: мы узнаем, находится точка слева или справа от прямой, образованной стороной прямоугольника "сцены". С какой стороны находится точка мы узнаем по векторному произведению.

Если нужно двигать, мы узнаем расстояние от точки до прямой, умножаем это на нормальный вектор\vec{n} и прибовляем к результирующему вектору перемещения.

if (!isInSceneZone) {
      final transferVector = _movePoint(markerPoint, sceneRect);
      final newLat = cameraPositionPoint.latitude.value - transferVector.y;
      final newLon = cameraPositionPoint.longitude.value - transferVector.x;
      cameraPositionPoint = cameraPositionPoint.copyWith(
        latitude: Latitude(newLat),
        longitude: Longitude(newLon),
      );
}
Расчеты

Метод _movePoint сдвигает маркер P внутрь сцены, если он выходит за любую из сторон прямоугольника P1P2P3P4 (точки идут по часовой стрелке). Для каждой стороны AB с соседней вершиной C вычисляется вектор смещения, затем все смещения суммируются.

1) Базовые векторы для стороны и соседней стороны:

\vec{AB} = B - A,\vec{n} = \dfrac{\vec{BC}}{\lVert \vec{BC}\rVert} — единичная нормаль вдоль следующей стороны.

2) Проверка положения точки относительно стороны:

c = \vec{AB} \times \vec{AP} = AB_x \cdot AP_y - AB_y \cdot AP_x

Еслиc \le 0 (точка «внутри» относительно хода по часовой стрелке), сдвиг не нужен.

3) Еслиc > 0, расстояние от точки до линииAB:

d = \lVert \vec{AP}\rVert \cdot \sin\alpha = \dfrac{c}{\lVert \vec{AB}\rVert}

4) Вклад смещения для стороны:

\Delta = \vec{n} \cdot d

5) Итоговый перенос:

\vec{t} = \sum \Delta_i по всем четырём сторонам; новая позиция камеры смещается на -t (долготу и широту уменьшаем на t.x, t.y).

В коде предварительно все координаты умножаются на константу-фактор, чтобы повысить числовую устойчивость, но итоговая формула соответствует описанным шагам.

4. Точка там, где надо

В этом случае мы ничего не делаем.

Яндекс.Карты и Google Maps

Если нужно поведение как у других картографических приложений, то на пункте 3 мы не используем _movePoint, а просто делаем то же, что и в пункте 2 и перемещаем камеру маркер. Выглядеть это может как-то так:

if (!isInSceneZone) {
    camera.moveToCameraPosition(
        // Заметьте, что в данном случае мы оставляем Zoom тем же, чтобы не было рывков камеры
        newCameraPosition.copyWith(
            point: markerPoint,
        ),
    );
    return;

Заключение

Спасибо за то, что прочитали этот пост. Надеюсь, он будет вам полезен (или вашей LLM'ке). Буду благодарен за конструктивную критику.

Все материалы вы можете найти тут. Однако есть пара моментов, если вы захотите запутить проект. Нужно будет получить ключ dgissdk.key и положить его в папку assets в корне приложения + удалить vendorConfig:snapshot.data! из инициализатора sdkContext ??= sdk.DGis.initialize(vendorConfig: snapshot.data!);, если он вам не нужен. Файл vendor_config.json так же лежит в папке assets в виде json'а. Тут, думаю, разберетесь.

Документация по 2GIS SDK на Flutter тут

Комментарии (0)