Всем привет! Я, как и многие здесь, не только программист, но и большой любитель активного отдыха. Велосипед, походы, горы — все это требует тщательного планирования. И хотя существует множество отличных сервисов, мне всегда хотелось чего-то большего: платформы, которая объединяла бы в себе гибкий инструмент для создания маршрутов, базу знаний о интересных местах и сообщество единомышленников.
Так я начал в одиночку создавать 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!
Комментарии (0)
 - ZdrasteEtoYa14.09.2025 10:57- Идея может быть хорошая, если есть инфа о покрытии и пр.. - Я бы улучшил дизайн составляющую, т.к. кнопки оранжевые, и заголовки оранжевые. Глаза разбегаются, т.к. функционал элементов разный, а элементы выглядят одинаково (не считая размера). - Заголовки, без подложки жирным смотрелись бы интуитивнее. Возможно, стоит ограничиться 3 базовыми цветами (+их оттенки) - primary, additional, accent. - Чисто как идея. - Желаю успехов с проектом, развивайте! 
 - Haroy14.09.2025 10:57- Крутая идея. Тоже работаю над проектом связанным с туризмом, только я почти полный 0 в программировании. Делаю через flutterflow. Уже почти год. И конца даже не видно 
 
           
 
Mingun
Планировщик просто не работает. Ошибка синтаксиса в файле
route-planner.js:64:15:Uncaught SyntaxError: unexpected token: ':'. При клике указывает на этот фрагмент кода:Очевидно, криво закомментировали отладочный вывод. (А ещё почему-то он загрузился не в кодировке utf-8, хотя это явно она).