Leaflet, роутинг и тонна JavaScript: создаем свой планировщик маршрутов с нуля
- воскресенье, 14 сентября 2025 г. в 00:00:03
Всем привет! Я, как и многие здесь, не только программист, но и большой любитель активного отдыха. Велосипед, походы, горы — все это требует тщательного планирования. И хотя существует множество отличных сервисов, мне всегда хотелось чего-то большего: платформы, которая объединяла бы в себе гибкий инструмент для создания маршрутов, базу знаний о интересных местах и сообщество единомышленников.
Так я начал в одиночку создавать The Peakline — свой большой проект для аутдор-энтузиастов. Одной из центральных и самых сложных частей этой системы должен был стать планировщик маршрутов. Я решил сделать его максимально функциональным и открытым, чтобы он стал витриной возможностей всего проекта.
В этой статье я хочу провести вас "за кулисы" и показать, как устроен фронтенд именно этой части моего проекта. Мы углубимся в архитектуру на чистом JavaScript, поговорим об интеграции с картографическими API и о тех неочевидных проблемах, с которыми сталкиваешься, когда делаешь такой продукт в одиночку.
Важный дисклеймер: Весь проект, от идеи до кода, я делаю один в свободное от основной работы время. Он далек от идеала (и очень даже), и я буду очень благодарен за конструктивную критику и свежий взгляд.
Тут вы можете сразу ознакомиться с подробными записями работы с планировщиком.
Приглашаю вас изучить как сам проект, так и его ключевую фичу:
Основной проект The Peakline: https://www.thepeakline.com/
Планировщик маршрутов (о котором статья): https://www.thepeakline.com/route-planner
Репозиторий на GitHub: https://github.com/CyberScoper/peakline-route-planner
А теперь — к техническим деталям.
Общий вид интерфейса планировщика в ThePeakline
Первый и главный вопрос: почему не React/Vue/Svelte? Ответ прост: я хотел полного контроля и минимального оверхэда. Работа с картами — это часто прямое манипулирование слоями, маркерами и событиями, которые предоставляет сама библиотека карт (в моем случае Leaflet). Оборачивать это все в реактивную модель фреймворка показалось мне излишним усложнением. К тому же, это был отличный челлендж — построить управляемое приложение на "чистом" JavaScript.
Чтобы не утонуть в "лапше" из колбэков, я разделил всю логику на три смысловых модуля, реализованных как IIFE (Immediately Invoked Function Expression) для инкапсуляции состояния:
frontend/
├── js/
│ ├── route-planner.js # "Контроллер" и "Представление" (View/Controller)
│ ├── route-manager.js # "Модель" (Model/State)
│ └── route-export.js # Утилитарный сервис
route-planner.js
(View/Controller): Этот модуль — дирижер оркестра. Он инициализирует Leaflet, отрисовывает UI, слушает все действия пользователя (клики по карте, нажатия кнопок, изменения в полях ввода) и делегирует обработку логики "Мозгу". Он единственный, кто "знает" о существовании DOM.
route-manager.js
(Model): Это "мозг" и единственный источник правды (Single Source of Truth). Он хранит массив точек маршрута, его метаданные (название, тип), рассчитанную статистику. Он предоставляет публичный API для изменения этих данных (addPoint
, undo
, setRoutingEngine
), но ничего не знает о том, как эти данные отображаются.
route-export.js
(Service): Чистый, без состояния, модуль-конвертер. Его задача — взять данные из route-manager
и преобразовать их в строки форматов GPX, KML или TCX.
Коммуникация между ними простая: route-planner
вызывает публичные методы route-manager
. После каждого изменения состояния route-planner
запрашивает актуальные данные у route-manager
и полностью перерисовывает все, что нужно, на карте и в UI. Просто, но эффективно.
Одной из функций, которой я горжусь больше всего, является "Анализ маршрута". Мне хотелось, чтобы пользователь получал не просто сухие цифры дистанции, а полное представление о предстоящем пути: какое покрытие его ждет, насколько сложным будет маршрут и какие рекомендации можно дать.
Проблема в том, что получить реальные данные о типе покрытия для каждой точки маршрута в реальном времени — задача почти невыполнимая без доступа к дорогим коммерческим GIS-API. Поэтому я пошел по другому пути: создал систему реалистичной симуляции.
Все начинается, когда пользователь нажимает кнопку "🔍 Анализ". Это вызывает асинхронную функцию analyzeRouteSurface()
в route-planner.js
. Ее задача проста:
Проверить, что в маршруте есть хотя бы 2 точки.
Очистить предыдущие результаты анализа.
Запустить генерацию "mock" (симулированных) данных о поверхности.
Отрисовать результаты на карте и в специальной панели.
Это сердце всей системы. Вместо того чтобы просто выдавать случайные данные, я постарался сделать симуляцию умной и зависимой от контекста. Функция generateRealisticSurface()
учитывает тип активности пользователя, который определяется по средней скорости, установленной в настройках.
Если пользователь — велосипедист (скорость > 20 км/ч), симуляция будет с большей вероятностью генерировать участки с асфальтом.
Если пользователь — пеший турист (скорость < 8 км/ч), в маршруте появится больше грунта и тропинок.
Вот как это выглядит в коде (упрощенно):
// Упрощенный пример из route-planner.js
generateRealisticSurface(segmentIndex, segmentLength, totalLength, activityType) {
const surfaces = ['асфальт', 'грунт', 'гравий', 'тропинка', ...];
const conditions = ['отличное', 'хорошее', 'плохое', ...];
// Разные "веса" вероятности для разных активностей
let surfaceWeights;
if (activityType === 'cycling') {
// У велосипедистов 70% шанс на асфальт
surfaceWeights = { 'асфальт': 0.7, 'грунт': 0.2, 'гравий': 0.05, ... };
} else if (activityType === 'hiking') {
// У туристов только 30% шанс на асфальт, но 40% на грунт
surfaceWeights = { 'асфальт': 0.3, 'грунт': 0.4, 'гравий': 0.15, ... };
}
// Выбираем поверхность и состояние на основе этих вероятностей
const selectedSurface = this.getWeightedRandom(surfaceWeights);
const selectedCondition = this.getWeightedRandom(...);
// Рассчитываем сложность и пригодность
const difficulty = this.calculateDifficulty(selectedSurface, selectedCondition);
const suitability = this.getSuitability(activityType, selectedSurface);
return { surface: selectedSurface, condition: selectedCondition, difficulty, suitability };
}
Для каждого сегмента маршрута (участка между двумя точками, поставленными вручную) генерируется тип поверхности, ее состояние, сложность (от 1 до 5) и пригодность (для какого вида активности подходит).
После генерации данных их нужно красиво показать.
На карте: Функция createSurfaceAnalysisVisualization()
пробегается по каждому симулированному сегменту и рисует поверх основной линии маршрута толстую цветную полилинию. Цвет зависит от типа покрытия (асфальт — зеленый, грунт — оранжевый, гравий — красный), а для плохого состояния добавляется пунктирный стиль.
В панели анализа: Метод showSurfaceAnalysisResults()
отвечает за рендеринг той самой красивой панели со статистикой:
Общая информация: Рассчитывает и выводит суммарные данные (средняя сложность, время прохождения).
Распределение поверхностей: Строит наглядный прогресс-бар, показывающий процентное соотношение типов покрытия.
Детальный анализ сегментов: Создает таблицу, где каждый сегмент маршрута разобран по косточкам.
Рекомендации: Самая умная часть. Это блок if-else
правил, который анализирует полученные данные и дает советы. Например:
Если более 70% маршрута подходит для велосипеда, он пишет "Отлично для велосипеда!".
Если средняя сложность выше 3.5, он предупреждает "Высокая сложность".
Если в маршруте много гравия, он может посоветовать "Рассмотрите шины с хорошим сцеплением".
Таким образом, функция анализа — это не просто показ случайных картинок, а целая система, которая пытается быть контекстно-зависимой и давать пользователю действительно полезную, хоть и симулированную, информацию. На мой взгляд, это отличный пример того, как можно обогатить пользовательский опыт, даже не имея доступа к идеальным данным.
Сердце приложения — это, конечно, карта. Я выбрал Leaflet.js за его легковесность, огромное количество плагинов и простоту API.
1. Инициализация и слои
Все начинается стандартно. Но важно не забыть про возможность смены подложки — это сильно повышает удобство.
// route-planner.js
const mapLayers = {
'OSM': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { ... }),
'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { ... }),
'OSM HOT': L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', { ... })
};
const map = L.map('map-container', {
layers: [mapLayers['OSM']] // Слой по умолчанию
}).setView([48.14, 17.10], 12);
L.control.layers(mapLayers).addTo(map); // Добавляем стандартный контрол для переключения
Карта с открытым переключателем слоев
2. Интеграция с движками маршрутизации
Это ключевая фича. У меня есть ручной режим (точки соединяются прямыми линиями) и автоматический, который использует внешние API. Как это работает?
Когда пользователь кликает по карте, route-planner
смотрит, какой режим сейчас активен.
Ручной режим: Просто добавляем точку в route-manager
.
Автоматический режим (OSRM, OpenRouteService и др.): Здесь все интереснее. Нам нужна не только последняя точка, но и предыдущая, чтобы построить сегмент маршрута между ними.
// route-planner.js: Упрощенная логика обработчика клика
async function handleMapClick(event) {
const newCoords = [event.latlng.lat, event.latlng.lng];
const currentMode = document.getElementById('routing-mode-selector').value;
if (currentMode === 'manual') {
RouteManager.addPoint(newCoords);
} else {
const points = RouteManager.getPoints();
if (points.length > 0) {
const lastPoint = points[points.length - 1];
UIRenderer.showLoading(true);
try {
const routeSegment = await RoutingService.fetchRoute(lastPoint, newCoords, currentMode);
RouteManager.addRouteSegment(routeSegment);
} catch (error) {
console.error("Routing API error:", error);
UIRenderer.showError("Не удалось построить маршрут.");
} finally {
UIRenderer.showLoading(false);
}
} else {
RouteManager.addPoint(newCoords);
}
}
redrawEntireRoute();
}
Крупный план панели режимов и настроек
Этот модуль — скала, на которой все держится. Он ничего не знает про DOM и Leaflet, он оперирует исключительно данными. Это позволяет держать бизнес-логику изолированной и легко тестируемой (в будущем).
1. Структура данных
Просто хранить массив координат было бы недальновидно. Поэтому точка маршрута (waypoint
) — это объект с дополнительной информацией:
// waypoint object structure
{
lat: 48.123,
lng: 17.456,
ele: 150, // Высота, полученная от API
isNode: true // Флаг: это точка, кликнутая пользователем, или промежуточная?
}
Флаг isNode
оказался критически важным. Он позволяет отрисовывать маркеры только в тех местах, где пользователь действительно кликнул, в то время как сама линия маршрута может состоять из сотен промежуточных точек, которые вернул роутер для сглаживания.
2. Управление состоянием и "Отмена"
Чтобы реализовать функцию "Отменить шаг", я использую простой стек состояний. Перед каждой операцией, изменяющей маршрут (addPoint
, addRouteSegment
), я сохраняю текущее состояние в массив history
.
// route-manager.js
const history = [];
let routePoints = [];
function saveState() {
// Важно делать глубокую копию, иначе в истории будут ссылки на один и тот же массив
history.push(JSON.parse(JSON.stringify(routePoints)));
if (history.length > 20) { // Ограничиваем историю, чтобы не съесть всю память
history.shift();
}
}
return {
addPoint: function(coords) {
saveState();
routePoints.push({ lat: coords[0], lng: coords[1], isNode: true });
// ...
},
undo: function() {
if (history.length > 0) {
routePoints = history.pop();
} else {
routePoints = []; // Если история пуста, просто очищаем
}
}
// ...
};
Спланировать маршрут — это полдела. Настоящая польза от инструмента появляется тогда, когда трек можно загрузить в GPS-навигатор или часы. Для этого данные нужно конвертировать в стандартные форматы.
1. Генерация GPX
Формат GPX — это де-факто стандарт для обмена GPS-данными. Это обычный XML-файл, поэтому его можно сгенерировать простой конкатенацией строк.
// route-export.js: Пример генератора GPX
function toGPX(points, routeName = "My Route") {
const pointsXml = points
.map(p => {
let pointTag = `<trkpt lat="${p.lat}" lon="${p.lng}">`;
if (p.ele) { // Добавляем высоту, если она есть
pointTag += `<ele>${p.ele}</ele>`;
}
pointTag += `</trkpt>`;
return pointTag;
})
.join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="ThePeakline Planner">
<trk>
<name>${routeName}</name>
<trkseg>
${pointsXml}
</trkseg>
</trk>
</gpx>`;
}
Крупный план левой панели с кнопками
2. Отдача файла пользователю
Чтобы браузер предложил скачать сгенерированную строку как файл, я использую стандартный трюк с созданием Blob и временной ссылки на него.
// В route-planner.js, по клику на кнопку экспорта
const gpxString = RouteExporter.toGPX(RouteManager.getPoints(), "Мой маршрут");
const blob = new Blob([gpxString], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'route.gpx'; // Имя файла по умолчанию
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url); // Чистим за собой
Когда ты один на один с проектом, проблемы приобретают особый вкус. Вот несколько граблей, на которые я наступил:
Производительность на длинных маршрутах. Когда в треке >10 000 точек (а роутер может вернуть и столько), перерисовка полилинии на каждый mousemove
начинает тормозить. Решение: использовать debounce
для событий мыши и искать оптимизированные плагины для рендеринга.
Rate-лимиты API. Бесплатные API роутинга имеют ограничения. Быстро кликая, можно легко их превысить. Решение: добавить throttle
на вызовы API и показывать пользователю вежливое уведомление, если поймали 429 ошибку (Too Many Requests).
Управление состоянием UI. Поначалу состояние интерфейса (какой движок выбран, какой транспорт) было "размазано" по DOM-элементам. Это был кошмар в отладке. Решение: вынести все это в единый объект state
в route-planner.js
, а DOM обновлять только на основе этого объекта. По сути, я вручную реализовал мини-версию реактивности.
Планировщик — это лишь часть большого пути. Моя главная цель — развивать его в тесной связке со всей платформой The Peakline. Вот что в планах:
Полная интеграция с экосистемой: Главный приоритет — возможность сохранять созданные маршруты в профиль пользователя The Peakline, давать им описания, прикреплять фотографии из реальных походов.
Социальные функции: Возможность поделиться маршрутом с друзьями внутри платформы, комментировать и оценивать чужие треки.
Интерактивный профиль высот: Построение графика высот под картой с синхронизацией маркера на карте при наведении на график.
Импорт треков: Не только экспортировать, но и загружать существующие GPX/KML для редактирования и сохранения в свою библиотеку.
Создание "Энциклопедии": Точки интереса (POI), которые сейчас есть в планировщике, должны стать частью большой базы знаний на основном сайте, с описаниями, фотографиями и отзывами.
Создание такого продукта в одиночку — это марафон, а не спринт. Это невероятно сложный, но и безумно интересный опыт. Планировщик — это первая большая фича The Peakline, которую я готов показать широкой аудитории. Да, он еще сырой, в нем могут быть баги, и ему не хватает многих функций, которые есть у "больших" игроков. Но он сделан с душой и большим желанием создать действительно полезный инструмент.
И здесь мне очень нужна ваша помощь.
Призыв №1: Оцените сам планировщик
Пожалуйста, попробуйте создать свой маршрут мечты в планировщике. Потыкайте во все кнопки, попробуйте разные движки. Если найдете баг (а вы найдете), у вас появится идея по улучшению или просто захочется что-то сказать — пишите в комментариях или (что будет вообще идеально) создавайте issue на GitHub.
Призыв №2: Изучите весь проект The Peakline
Загляните на главную страницу проекта, чтобы понять общую концепцию. Планировщик — лишь один из кирпичиков. Мне очень важно услышать ваше мнение о проекте в целом: нужна ли такая платформа, что в ней должно быть в первую очередь?
(могут долго прогружаться)
Спасибо, что дочитали эту длинную статью. Буду рад любому фидбэку и, конечно, буду счастлив видеть вас среди пользователей The Peakline!