Всем привет! Я, как и многие здесь, не только программист, но и большой любитель активного отдыха. Велосипед, походы, горы — все это требует тщательного планирования. И хотя существует множество отличных сервисов, мне всегда хотелось чего-то большего: платформы, которая объединяла бы в себе гибкий инструмент для создания маршрутов, базу знаний о интересных местах и сообщество единомышленников.
Так я начал в одиночку создавать 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
Архитектура: почему Vanilla JS и как не запутаться в коде
Первый и главный вопрос: почему не 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. Поэтому я пошел по другому пути: создал систему реалистичной симуляции.

Шаг 1: Точка входа — analyzeRouteSurface()
Все начинается, когда пользователь нажимает кнопку "? Анализ". Это вызывает асинхронную функцию analyzeRouteSurface()
в route-planner.js
. Ее задача проста:
Проверить, что в маршруте есть хотя бы 2 точки.
Очистить предыдущие результаты анализа.
Запустить генерацию "mock" (симулированных) данных о поверхности.
Отрисовать результаты на карте и в специальной панели.
Шаг 2: Мозг симуляции — generateMockSurfaceAnalysis()
Это сердце всей системы. Вместо того чтобы просто выдавать случайные данные, я постарался сделать симуляцию умной и зависимой от контекста. Функция 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) и пригодность (для какого вида активности подходит).
Шаг 3: Визуализация — createSurfaceAnalysisVisualization() и showSurfaceAnalysisResults()
После генерации данных их нужно красиво показать.
На карте: Функция
createSurfaceAnalysisVisualization()
пробегается по каждому симулированному сегменту и рисует поверх основной линии маршрута толстую цветную полилинию. Цвет зависит от типа покрытия (асфальт — зеленый, грунт — оранжевый, гравий — красный), а для плохого состояния добавляется пунктирный стиль.-
В панели анализа: Метод
showSurfaceAnalysisResults()
отвечает за рендеринг той самой красивой панели со статистикой:Общая информация: Рассчитывает и выводит суммарные данные (средняя сложность, время прохождения).
Распределение поверхностей: Строит наглядный прогресс-бар, показывающий процентное соотношение типов покрытия.
Детальный анализ сегментов: Создает таблицу, где каждый сегмент маршрута разобран по косточкам.
-
Рекомендации: Самая умная часть. Это блок
if-else
правил, который анализирует полученные данные и дает советы. Например:Если более 70% маршрута подходит для велосипеда, он пишет "Отлично для велосипеда!".
Если средняя сложность выше 3.5, он предупреждает "Высокая сложность".
Если в маршруте много гравия, он может посоветовать "Рассмотрите шины с хорошим сцеплением".
Таким образом, функция анализа — это не просто показ случайных картинок, а целая система, которая пытается быть контекстно-зависимой и давать пользователю действительно полезную, хоть и симулированную, информацию. На мой взгляд, это отличный пример того, как можно обогатить пользовательский опыт, даже не имея доступа к идеальным данным.
route-planner.js: Магия интерактивной карты
Сердце приложения — это, конечно, карта. Я выбрал 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();
}

Крупный план панели режимов и настроек
route-manager.js: Хранитель состояния
Этот модуль — скала, на которой все держится. Он ничего не знает про 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 = []; // Если история пуста, просто очищаем
}
}
// ...
};
route-export.js: Упаковываем данные в дорогу
Спланировать маршрут — это полдела. Настоящая польза от инструмента появляется тогда, когда трек можно загрузить в 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. Вот что в планах:
Полная интеграция с экосистемой: Главный приоритет — возможность сохранять созданные маршруты в профиль пользователя The Peakline, давать им описания, прикреплять фотографии из реальных походов.
Социальные функции: Возможность поделиться маршрутом с друзьями внутри платформы, комментировать и оценивать чужие треки.
Интерактивный профиль высот: Построение графика высот под картой с синхронизацией маркера на карте при наведении на график.
Импорт треков: Не только экспортировать, но и загружать существующие GPX/KML для редактирования и сохранения в свою библиотеку.
Создание "Энциклопедии": Точки интереса (POI), которые сейчас есть в планировщике, должны стать частью большой базы знаний на основном сайте, с описаниями, фотографиями и отзывами.
Заключение и призыв к действию
Создание такого продукта в одиночку — это марафон, а не спринт. Это невероятно сложный, но и безумно интересный опыт. Планировщик — это первая большая фича The Peakline, которую я готов показать широкой аудитории. Да, он еще сырой, в нем могут быть баги, и ему не хватает многих функций, которые есть у "больших" игроков. Но он сделан с душой и большим желанием создать действительно полезный инструмент.
И здесь мне очень нужна ваша помощь.
Призыв №1: Оцените сам планировщик
Пожалуйста, попробуйте создать свой маршрут мечты в планировщике. Потыкайте во все кнопки, попробуйте разные движки. Если найдете баг (а вы найдете), у вас появится идея по улучшению или просто захочется что-то сказать — пишите в комментариях или (что будет вообще идеально) создавайте issue на GitHub.
Призыв №2: Изучите весь проект The Peakline
Загляните на главную страницу проекта, чтобы понять общую концепцию. Планировщик — лишь один из кирпичиков. Мне очень важно услышать ваше мнение о проекте в целом: нужна ли такая платформа, что в ней должно быть в первую очередь?
Записи работы с планировщиком.
(могут долго прогружаться)



Спасибо, что дочитали эту длинную статью. Буду рад любому фидбэку и, конечно, буду счастлив видеть вас среди пользователей The Peakline!
Комментарии (3)
isumix
31.08.2025 07:38Первый и главный вопрос: почему не React/Vue/Svelte? Ответ прост: я хотел полного контроля и минимального оверхэда. Работа с картами — это часто прямое манипулирование слоями, маркерами и событиями, которые предоставляет сама библиотека карт (в моем случае Leaflet). Оборачивать это все в реактивную модель фреймворка показалось мне излишним усложнением.
Делал Фьюзор как раз для таких задач. Полный контроль над тем как DOM ноды создавать и как их обновлять, синхронно или нет, всё дерево либо некоторые, либо сделать исключения из дерева... Нет стейт менеджера по умолчанию, но можно подключить любой, либо переменные использовать. Также нет реактивности по умолчанию, но можно легко к каждой ноде подключить на пропсу
mount
.
dom1n1k
31.08.2025 07:38С точки зрения UI тут конечно конь не валялся, но больше всего мне понравилось оригинальное решение таблицы с сегментами :)
Типы покрытия кодируются красным/зеленым/етц цветами - и тут же фиганём огромный красно-зеленый градиент через всю шапку! Ну чтобы он всё собой забил.
sunnybear
Ожидал, что после расставления точек будет построен маршрут по дорогам (при их наличии). Но выдано просто прямое расстояние. И время прохождения совсем не соответствует реальности (разница высот не учитывается)