Подписка на блог

РСС JSON Feed

Коломенский трамвай

Навигация на странице «Маршрут». Часть 1

Страница «Маршрут» — единственная, которая имеет две шапки вверху сайта.

Вторая навигация позволяет перемещаться по разным «вкладкам» этой страницы. При этом всплывающее окно в правой части (или внизу на мобильных устройствах) не сбрасывается при переходе между вкладками. Например, вы можете выбрать остановку в алфавитном списке на вкладке «Остановки», а затем проложить маршрут до другой остановки, найдя её на схеме.

В далеком 2022 году реализация этой фичи была чисто джаваскриптовая: кнопки в навигации вызывали метод switch_tab(tab), в аргумент которой передавалась нужная вкладка. HTML-страница включала 4 div-блока, и функция просто показывала один нужный блок и прятала остальные три. URL при этом не менялся: https://kolomna-trams.ru/route/

function switch_tab(tab) {
    if (tab == "") {
        tab = "map";
    }

    document.getElementById('content-lines').style.display = 'none';
    document.getElementById('content-stops').style.display = 'none';
    document.getElementById('content-visit').style.display = 'none';
    document.getElementById('content-map').style.display = 'none';

    document.getElementById('content-' + tab).style.display = 'block';
}

switch_tab("map");

О том, что это чудовищный костыль и так делать не нужно, мы догадались почти сразу. На каком-то из сайтов мы обнаружили использование хэшей (#...) для навигации. Оказалось, что JavaScript умеет отлавливать событие изменения хэша страницы и выполнять любое действие. Теперь сайт стал лучше: кнопки в навигации стали вполне себе ссылками, содержащими хэш:

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

function switch_tab() {
    let tab = window.location.hash;
    tab = tab.substring(1);
    if (tab == "") {
        tab = "scheme";
    }

    document.getElementById('content-lines').style.display = 'none';
    document.getElementById('content-stops').style.display = 'none';
    document.getElementById('content-visit').style.display = 'none';
    document.getElementById('content-map').style.display = 'none';

    document.getElementById('content-' + tab).style.display = 'block';
}

switch_tab();
window.addEventListener('hashchange', switch_tab);

Открылся дополнительный плюс этого метода — теперь у каждой вкладки появился уникальный URL, а значит и способ поделиться. К примеру, если ваш друг спрашивает, где можно посмотреть список остановок Коломенского трамвая, вы отправляете ему: https://kolomna-trams.ru/route/#stops, и нужная вкладка сама открывается у него на устройстве.

Увы, но такой способ имел большой недостаток. Технически, эти 4 вкладки были одной веб-страницей, и поисковиками индексировались как одна страница — а значит, открывалась она всегда на вкладке по умолчанию (на вкладке со схемой). И даже если условный Google смог сообразить, что на этой странице содержится информация об остановках трамваев, то если пользователь кликнет на ссылку из поисковой выдачи, он увидит:

А должен увидеть это:

Встала непростая задача. Нам нужно сделать так, чтобы четыре разные вкладки являлись разными веб-страницами (а значит, имели разные URL-адреса), при этом переход по ним не являлся переходом в прямом смысле этого слова (не перезагружал страницу).

Оказалось, что этому есть решение. Во-первых, мы переселили все вкладки на разные страницы:

Во-вторых, сделали кнопки в навигации настоящими ссылками на эти страницы. То есть на них можно даже нажать правой кнопкой и выбрать «Открыть в новой вкладке». А на маке ещё и так можно сделать:

А главный фокус состоит в следующем. При нажатии на кнопку JavaScript перехватывает нажатие и запрещает прямой переход по ссылке. Вместо этого он загружает содержание страницы себе во внутреннюю переменную, достает оттуда контент вкладки (то есть то, что под шапкой, — саму шапку, а также подвал, обновлять не надо) и заменяет её прямо на текущей странице.

function set_up_buttons() {
    var links = document.getElementsByClassName('trams-v3-subnav-link');
    for (let i = 0; i < links.length; i++) {
        const link_url = links[i].getAttribute('href');
        links[i].addEventListener("click", (event) => {
            event.preventDefault();
            switch_tab(link_url);
        });
    }
}

function switch_tab(url) {
    var xhttp = new XMLHttpRequest();
    xhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
	    build_page(this.responseText, url);
	    window.history.replaceState(null, null, url + window.location.hash);
	}
    };
    xhttp.open('GET', 'https://' + window.location.hostname + url);
    xhttp.send();
}

set_up_buttons();

А если у пользователя по какой-то причине отключен JavaScript или скрипт просто не подгрузился — ничего страшного, кнопки будут работать как обычные ссылки. Да, страница всё же будет перезагружаться и «магия» пропадёт, но зато человек сможет пользоваться сайтом, а не останется с нерабочим «кирпичом».

Прогнозирование загруженности: считаем людей

Чтобы модели было на чём обучаться, нужно собрать данные о загруженности. Данные должны быть в таком формате:

Маршрут Остановка Вошло Вышло В трамвае Время Другие данные...
4 Сад дворца 4 2 16 15:43 ...
4 Дворец культуры 7 3 20 15:45 ...

Как бы собрать такие данные?.. Оказывается, до нас умные люди уже задавались этим вопросом, и вот какие способы они придумали.

Во-первых, способы делятся по длительности охватываемого периода различают:

  • систематическое обследование — регулярные исследования
  • разовое обследование — кратковременные обследования для выявления конкретных результатов (для более точных результатов)

В нашем исследовании используются оба типа: разовые обследования для сбора первоначальных данных и систематические — для поддержания алгоритма в актуальном состоянии.

Во-вторых, по ширине охвата транспортной сети различают:

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

В нашем исследовании в основном используются сплошные обследования.

Ну и дальше самое интересное — виды обследования.

Анкетное исследование
Его суть в том, чтобы опрашивать людей об их перемещениях. Опрашивать можно по-разному: в трамваях, на остановках, в интернете, по телефону, в школах, университетах и на работе.

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

  • цель поездки
  • возраст пассажира
  • род занятий пассажира (школьник, студент, работающий, безработный)
  • где проживает
  • где учится или работает

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

Глазомерное исследование
Этот метод предполагает, что на каждой остановке водитель, кондуктор или учетчик заполняет поле в соответствующей форме, а именно — ему нужно проставить значение от 1 до 5, оценив заполненность салона по следующей схеме.

Зн. Заполненность
1 Заняты до половины мест для сидения
2 Заняты в основном все места для сидения
3 Заняты все места для сидения и до половины мест для проезда стоя
4 Трамвай заполнен полностью, но посадка ещё возможна
5 Трамвай переполнен, наблюдаются отказы в посадке

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

Весовой метод
Данный метод предписывает смонтировать на пневморессорах трамвая датчики, определяющие примерный вес салона. Вес можно сконвертировать в загруженность или даже в количество пассажиров (обычно среднюю массу одного пассажира берут за 70 кг). Недостатка у такого подхода два: во-первых, нужно монтировать эти датчики, а во-вторых, нет возможности вычислить, сколько человек вошло и вышло. Ну и точность, конечно, не ахти.

Табличный метод
Метод «в лоб». В трамваях (как правило, у дверей или на накопительной площадке сзади) располагаются учетчики, которые считают число вошедших и вышедших пассажиров, затем записывают их в таблицу. Этот метод самый точный и позволяет определить всю минимально необходимую информацию без влияния на пассажиров. Недостаток здесь очевиден — работа учетчиков не бесплатна, да и на все-все рейсы их не напасешься. Кроме того, всё же хочется как-то автоматизировать этот процесс.

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

Талонный метод
А вот здесь уже поинтереснее. Каждому пассажиру при посадке вручают специальный талон, который он должен сдавать учетчику при выходе из трамвая. Всё устроено так, что по талонам потом можно понять, сколько человек откуда и куда ехали. К примеру, нумерация талонов соответствует маршруту и остановке входа в трамвай, а для сортировки талонов по останове выхода используют разные стопки или «прокладки» внутри одной стопки.

Этот метод всегда является временным, так как пассажиры не то чтобы очень рады на постоянной основе участвовать в таких экспериментах.

Отчётно-статистический метод
Этот метод предлагает считать пассажиров косвенными методами — например, по количеству проданных билетов. Этот способ хорош своей легковесностью (билеты всё равно ведь продавать), а ещё с его помощью можно получить данные за прошлый период, ещё до того, как наше исследование начало проводиться. Но ничего, помимо числа людей, вошедших на каждой остановке, этот метод нам предоставить не может. Более того, если на какой-то из остановок вошло очень много людей, то кондуктор может не успеть продать всем билеты, пока трамвай ещё не доехал до следующей остановки, и тогда некоторые из пассажиров попадут в учет следующей остановки. Наконец, не стоит забывать о том, что некоторые пассажиры пытаются проехать без билета (это особенно актуально для трамваев, где вместо кондуктора используются валидаторы), что влияет на точность сбора данных.

Автоматизированные методы
Это группа методов, посвященная сбору данных через автоматизированные устройства — датчики прохода в дверях, контактные ступеньки, камеры с искусственным интеллектом. Все их объединяет низкая точность в сборе данных: низкий пассажир может спрятаться от камеры, пара быстрых пассажиров считаются за одного, некоторые стоят в дверях и мешают работе датчиков.

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

Вначале (перед поездкой) исследователь регистрирует свою поездку, указывая:

  • номер маршрута
  • модель трамвая (от неё зависит низкопольность и число мест)
  • дату поездки
  • информацию о погоде (силу дождя или снега, скорость ветра, температуру, есть ли гроза и туман)

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

Собранные данные можно посмотреть на нашем GitHub.

Вышла статья «Оптимизация сети маршрутов трамваев методами искусственного интеллекта»

Вышла статья «Оптимизация сети маршрутов трамваев методами искусственного интеллекта» в Институте прикладной математики им. М.В. Келдыша Российской академии наук.

Написали статью разработчик и автор проекта Дмитрий Балабанов и его научный сотрудник Владимир Судаков.

Статья посвящена оптимизации маршрутной сети трамваев на примере города Коломны с использованием методов искусственного интеллекта. Мы формализовали существующую трамвайную сеть как ориентированный граф, ввели понятие «комфорта пассажира» (через время ожидания, переполненность и число пересадок), выведенное на основе опроса и логистической регрессии. Задача сводится к распределению 40 трамвайных вагонов по возможным маршрутам (всего их 55) так, чтобы максимизировать суммарный комфорт. Из-за комбинаторной сложности (≈10¹¹ вариантов) мы применяем семь методов оптимизации: жадный алгоритм, генерацию столбцов, перебор пар маршрутов, генетический алгоритм, соревнование агентов, кооперацию агентов и имитацию отжига.

Подробнее о проекте можно узнать здесь в блоге по тегу «оптимизация трамвайной сети».

Почитать о статье можно здесь.
https://library.keldysh.ru/preprint.asp?id=2026-14

А саму статью (бесплатно) — здесь.
https://keldysh.ru/papers/2026/prep2026_14.pdf

Стоимость проезда с 1948 по 2026

Если перейти по ссылке:

https://kolomna-trams.ru/workshop/wayback/museum/2007/

то вы увидите, что в 2007 году проезд в трамвае стоил 12 рублей. Можете попробовать любой другой год, просто поменяв число в URL — всё работает.

Открытых данных по стоимости проезда в одном месте нигде не было, поэтому пришлось искать эту информацию в разных источниках.

В 2026 году (на момент публикации заметки) в Коломне действуют 6 разных тарифов:

  1. Оплата банковской картой. Стабильно 73 рубля
  2. Оплата картами «Стрелка» и «Тройка». 73 рубля, но каждые 10 поездок стоимость снижается на 7%, с 51-й поездки снижаться перестает
  3. Оплата ученической «Стрелкой» (для школьников и студентов). Скидка 50% с 1-й по 35-ю поездку и 99% с 36-й поездки
  4. Оплата льготной «Стрелкой» (для ветеранов). Скидка 50%
  5. Оплата (если можно так назвать) картой пенсионера, скидка 100% (бесплатный проезд)
  6. Дети до 7 лет могут ехать бесплатно

Эту информацию мы узнали из https://kolomna-spravka.ru/novosti/s-1-yanvarya-proezd-v-obschestvennom-transporte-podorozhaet. Вообще так делать нехорошо: «Коломна Справка» — это просто новостной портал, а официальные тарифы устанавливаются постановлением правительства «Об утверждении регулируемых тарифов на перевозку пассажиров и багажа автомобильным транспортом и городским наземным электрическим транспортом...» и правильнее бы ссылаться на этот документ. Но, забегая вперед, чем дальше в прошлое мы идём, тем сложнее искать информацию, поэтому будем считать, что новостные агрегаторы правильно печатают тарифы.

Кстати, чтобы эта страница не стала в будущем грустным кладбищем истории с неработающими ссылками, мы на всякий случай заархивировали все ссылки на этой странице. Если что-то не работает — открывайте дамп на веб-архиве.

Схема оплаты «банковская карта + Стрелка + Тройка» будет с нами ещё долго, и информация в основном будет открытой и находиться по первой ссылке в Google.

с какой даты карта Стрелка/Тройка источник
1 янв 2026 73 руб 73 руб Коломна-Справка · архив
1 июня 2025 64 руб 63 руб Мск1 · архив
1 янв 2025 60 руб 59 руб Мск1 · архив
20 мая 2024 54 руб 54 руб Интерфакс · архив
1 ноя 2023 50 руб 49,47 руб РамМедиа · архив
1 янв 2023 45 руб 44,97 руб АБН24 · архив

Предыдущее изменение тарифов было 1 января 2022, но в этом промежутке случилось ещё одно событие — в общественном транспорте Подмосковья перестали принимать наличные. Случилось это 18 июня.

Как сообщает источник «Королёв. Без формата», до отмены проезд за наличные стоил 64 рубля — гораздо дороже, чем по карте. Теперь в таблицу добавляем ещё одну колонку — стоимость за наличные.

с какой даты наличные карта Стрелка/Тройка источник
18 июня 2022 41 руб 40,41 руб Одинцовский ГО · архив
1 янв 2022 64 руб 41 руб 40,41 руб Правительство МО · архив
1 фев 2021 58 руб 37 руб 36,74 руб Селдон Ньюс · архив

Здесь опять стоит прерваться. С 1 февраля 2021 в общественном транспорте Подмосковья стали принимать карту «Тройка». Соответственно, до этого оплата по неё не принималась. Источник — Администрация Пушкина.

с какой даты наличные карта Стрелка источник
1 янв 2021 58 руб 37 руб 36,74 руб Селдон Ньюс · архив
3 июня 2020 56 руб 36 руб 35,46 руб Мир24 · архив
22 апр 2020 35,46 руб Контур · архив
1 янв 2020 56 руб 36 руб 35,46 руб Мосрег · архив

С 22 апреля по 3 июня 2020, во время пандемии COVID-19, оплачивать проезд можно было только по социальной карте или по транспортной карте «Стрелка», поскольку к ним можно было привязать цифровой пропуск. Проезд за наличные был запрещен из-за того, что купюры и монеты легко передают инфекцию.

с какой даты наличные карта Стрелка источник
1 янв 2019 53 руб 34,43 руб 34,43 руб Российская газета · архив
1 мар 2018 48 руб 32,92 руб 32,92 руб Зеленоград сегодня · архив

1 марта 2018 года — первый день, когда начали принимать банковские карты для оплаты проезда. До этого можно было оплатить либо наличными, либо картой «Стрелка». Источник — Правительство МО

с какой даты наличные Стрелка источник
1 янв 2018 48 руб 32,92 руб Зеленоград сегодня · архив
1 янв 2017 46 руб 31,65 руб Российская газета · архив
1 янв 2016 43 руб 30 руб Интерфакс · архив

В 2015-2016 году появилась карта «Стрелка». За это время случилось много событий, смотрите сами:

  • 1 янв 2015. Очередное повышение тарифов до 28 рублей
  • 1 фев 2015. Появилась карта «Стрелка». Проезд по ней стоит те же 28 рублей, но с каждой 10-й поездкой стоимость снижается. 51-я поездка и далее стоят всего 18 рублей
  • 1 мая 2015. Появились ученическая и льготная «Стрелки». Проезд по ним стоит половину стандартного тарифа — 14 рублей
  • 30 мая 2015. Резко выросла цена на оплату наличными — 40 рублей. Проезд по «Стрелке» остался стоить столько же — 28 рублей. Видимо, нужно было стимулировать пассажиров, привыкших к наличным, использовать карты.
  • 1 янв 2016. Очередное повышение тарифов
  • 1 фев 2016. Появился супер-тариф для студентов со скидкой 99% начиная с 36-й поездки. До этого студенты платили фиксировано половину цены обычной «Стрелки» (к примеру, в 2016 году 50% от 30 рублей, то есть 15 рублей), а с 1 февраля — 15 рублей за первые 35 поездок и 1% от стандартной цены (1% от 30 рублей это 30 копеек) за все последующие поездки.

Всё то же самое, но в таблице:

с какой даты наличные Стрелка Уч. стрелка источник
1 фев 2016 43 руб 30 руб 15 руб/30 коп Минтранс · архив
1 янв 2016 43 руб 30 руб 15 руб Интерфакс · архив
30 мая 2015 40 руб 28 руб 14 руб Москва 24 · архив
1 мая 2015 28 руб 28 руб 14 руб Стрелка · архив
1 фев 2015 28 руб 28 руб Стрелка · архив
1 янв 2015 28 руб МСК-Агентство · архив

Мы остановились на том, что в января 2015 проезд за наличные стоил 28 рублей. В 2014 году он стоил 26 рублей (МСК-Агентство). При этом, также можно было купить абонемент. Всего было доступно три типа абонементов — на 60 поездок, 100 поездок и студенческий безлимитный проездной на месяц.

с какой даты наличные 60 п 100 п студ источник
1 янв 2014 26 руб 940 1490 470 Риа
1 янв 2013 25 руб 870 1380 435 Московский Комсомолец
1 фев 2011 23 руб 800 1270 400 Коломна-Справка
1 янв 2010 22 руб 800 1270 400 Интерфакс
1 янв 2009 20 руб 730 1150 365 КТ-Рузайн · архив
1 янв 2008 17 руб 600 950 300 КТ-Рузайн · архив
1 апр 2007 15 руб 500 750 250 Регнум

Обращаясь к источникам за 2006 год мы увидели, что веер проездных билетов изменился. Теперь есть такие виды:

  • обычный билет (одна поездка)
  • проездной на месяц
  • проездной на квартал
  • проездной на год
  • проездной на месяц на два транспорта (например, автобус и трамвай)
  • социальный проездной (для пенсионеров — да, раньше они катались за деньги)
  • студентам доступны все проездные, кроме социального, за половину стоимости
с какой даты 1 поездка месяц квартал год А+Т соц студ источник
1 мар 2006 12 руб 420 руб 1110 руб 4150 руб 680 руб 170 руб 1/2 стоимости Риа
25 апр 2004 10 руб 400 руб 1050 руб 3950 руб 650 руб 150 руб 175 руб Постановление

В 2002 году нас встречает лишь грустная статья от РБК, где сообщается, что с 25 ноября разовый билет стоит 7 рублей, проездной на месяц — 310 рублей, проездной для студентов — 155 рублей.

Для 2001 года даже распознать дату смены тарифов сложно. Мы предположили, что раз тарифы менялись в то время один раз в год, то изменение произошло 25 ноября 2001 года. ОКА-ФМ сообщает, что разовый билет тогда стоил 5 рублей, а до этого — 4 рубля.

На этом интернет можно закрывать. Сколько бы мы ни пытались, но найти информацию о стоимости проезда в 20 веке в интернете не вышло — видимо, не был тогда он настолько сильно развит. На помощь приходит книга Романа Славацкого и Виктора Боченкова «Серебряное кольцо. Очерки истории коломенского трамвая». Вот что в ней пишется:

В начале 90-х гг. инфляция приняла чудовищные размеры. Это можно проследить по ценам трамвайных талонов, которые в свое время продавались на остановках. Целыми десятилетиями мы платили за проездой раментальные три копейки. И эта сумма казалась незыблемой, как египетские пирамиды. Но в августе 1991 года цена подскочила в пять раз! Прошло полгода — и билет стоил уже 50 копеек. А в июле 1992 года за него просили рубль. М.М. Фарберов, как мог, убеждал областное правительство не повышать стоимость проезда. Ведь чем выше цена, тем меньше возможности приобрести билет. Но все доводы остались без внимания.

И вот в августе 1993 года за билет пришлось отдавать 10 рублей, а с ноября — уже 20. В январе коломенцев ждал новогодний «подарок»: с 15 числа за проезд брали 40 рублей. С началом весны «расцвела» и цена; в ход пошла сотня.

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

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

Что ж, спасибо авторам этой прекрасной книги! Заполняем пропуски в нашей истории цен.

с какой даты стоимость
15 мар 1994 100 руб
15 янв 1994 40 руб
ноя 1993 20 руб
авг 1993 10 руб
июль 1992 1 руб
фев 1992 50 коп
авг 1991 15 коп
5 ноя 1948 3 коп

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

Вышла статья «Прогнозирование загруженности трамваев методами машинного обучения»

Вышла статья «Прогнозирование загруженности трамваев методами машинного обучения» в Институте прикладной математики им. М.В. Келдыша Российской академии наук.

Написали статью разработчик и автор проекта Дмитрий Балабанов и его научный сотрудник Владимир Судаков.

В статье рассказывается о том, как мы разработали модель машинного обучения для прогнозирования загруженности вагонов трамваев в Коломне. Мы собрали и обработали данные за период с сентября 2024 по июнь 2025 года: GPS-треки трамваев, погоду, расписание пригородных поездов, количество остановок и мест притяжения (ТЦ, школы, вокзалы). Особенность нашего подхода — адаптация моделей к условиям малого города с ограниченным объёмом данных и упор на интерпретируемость результатов для пассажиров.

Мы сравнили три модели: линейную регрессию, случайный лес и градиентный бустинг. Лучший результат показал случайный лес (R² = 0,87, MAPE = 31%). Модель обучили без привязки к номеру маршрута, чтобы она могла работать с любыми маршрутами, ориентируясь на расстояния до остановок. Готовую модель внедрили в веб-сервис, где загруженность показывается не числом людей, а понятными значками.

Подробнее о проекте можно узнать здесь в блоге по тегу «прогнозирование загруженности».

Почитать о статье можно здесь.
https://library.keldysh.ru/preprint.asp?id=2026-12

А саму статью (бесплатно) — здесь.
https://keldysh.ru/papers/2026/prep2026_12.pdf

Поиск остановок

На не самой популярной странице «Остановки» есть функция поиска. Например:

Поиск умеет работать даже если допустить ошибку в слове. Можно даже не одну ошибку.

А ещё можно так:

Всё это работает в браузере без машинного обучения и искусственного интеллекта.

Формально задача звучит так: есть функция get_search_results, которая получает текст запроса и возвращает список остановок, которые пользователь мог иметь в виду.

function get_search_results(prompt) {
    ...
}

Самый простой способ — искать вхождение подстроки. Если пользователь ввёл «Калинина», найти все остановки, в названии которых есть «Калинина». Это работает, но только если пользователь не ошибся.

А если ошибся? Например, написал «Калинино» или «Калининв»?

Тогда нужен механизм, который умеет прощать ошибки.

Первый подход: перебор

Мы решили, что будем перебирать все возможные подстроки запроса и смотреть, сколько из них встречается в названии остановки. Чем больше совпадений — тем вероятнее, что пользователь имел в виду именно эту остановку.

Например, пользователь ввёл «Калининва». Разбиваем на подстроки разной длины:
— Длина 1: «К», «а», «л», «и», «н», «и», «н», «в», «а»
— Длина 2: «Ка», «ал», «ли», «ин», «ни», «ин», «нв», «ва»
— Длина 3: «Кал», «али», «лин», «ини», «нин», «инв», «нва»
— И так далее.

Потом смотрим, сколько из этих подстрок встречается в названии «Улица Калинина». Чем больше — тем выше точность.

Для коротких запросов, например «Кали», этот метод тоже работает: подстроки «К», «а», «л», «и», «Ка», «ал», «ли», «Кал», «али» — почти все найдутся в названии.

Но тут есть нюанс

Если запрос длинный, количество подстрок растёт квадратично. Для слова из 10 букв это 55 подстрок. Для 15 — уже 120. Перебирать 120 подстрок для каждой из сотни остановок — на клиенте это всё равно быстро, но мы решили ограничить максимальную длину запроса семью символами. Если пользователь ввёл длинное слово, дальше семи букв мы подстроки не режем.

Вот как выглядит основная логика:

for (let i = 1; i <= prompt.length; i++) {
    if (i >= 7) break;
    for (let j = 0; j < prompt.length - i + 1; j++) {
        const sub = prompt.substring(j, j + i);
        if (stp.Name.toLowerCase().includes(sub)) {
            acc_count += 1;
        }
    }
}

Счётчик acc_count накапливает количество совпадений. Потом мы делим его на максимально возможное количество совпадений для запроса этой длины. Получается число от 0 до 1 — степень уверенности, что остановка подходит.

Альтернативные названия

В Коломне у некоторых остановок есть народные названия. Например, «Трамвайное управление» в народе — просто «Депо». А остановка «Детская художественная школа» иногда называется «Путепровод» или «Холодильник» (что?).

Мы добавили массив Alternatives для каждой остановки. Если подстрока не нашлась в официальном названии, но нашлась в одном из альтернативных, мы тоже добавляем очко, но с понижающим коэффициентом. Потому что альтернативное название — это всё-таки не основное.

for (const alt of stp.Alternatives) {
    if (alt.includes(sub)) {
        acc_count += 1 / stp.Alternatives.length;
    }
}

Нашлось ещё одно применение Alternatives — разные написания названий. К примеру, для «Площади двух революций» добавили «Площадь 2 революций» — потому что люди часто заменяют слово «двух» на цифру. А для «Бульвара 800-летия Коломны» добавили «Бульвар восьмисотлетия Коломны» — кто-то вводит и так.

Порог срабатывания

На основе этого механизма мы написали отдельную функцию get_search_results_improved, которая возвращает все остановки, у которых степень уверенности превысила порог accuracy.

А порог accuracy вычисляем эмпирически:

let accuracy = search_results.length * 0.02 + prompt.length * 0.01 + 0.25;

Если обычный поиск (простое вхождение) нашёл много результатов — порог повышаем, чтобы не выдавать мусор. Если запрос длинный — тоже повышаем, потому что длинный запрос точнее.

А что, если пользователь ввёл всё правильно

Если пользователь не ошибся, зачем запускать тяжёлый алгоритм с подстроками? Поэтому в get_search_results сначала идут простые проверки:

  1. Точное совпадение с учётом заглавной буквы
  2. Совпадение с пробелом перед запросом (чтобы найти слово в середине названия)
  3. Простое вхождение в нижнем регистре
  4. Поиск по альтернативным названиям

Только после этого, если простые методы сработали, запускаем улучшенный поиск и докладываем его результаты сверху.

search_results.push(...get_search_results_improved(prompt, accuracy));

Дубликаты и ограничения

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

И в конце оставляем не больше семи результатов. Семь — хорошее число. Не перегружает интерфейс, но даёт достаточно выбора.

search_results = search_results.slice(0, 7);

Почему не Левенштейн

Знающие люди спросят: а почему вы не использовали расстояние Левенштейна? Оно же специально придумано для поиска с ошибками.

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

Но есть проблема. Остановка может называться «Улица Калинина», а пользователь вводит «Калинина». Расстояние Левенштейна будет большим, потому что в начале названия не хватает «Улица ». А наш алгоритм разбивает на подстроки и находит «Калинина» внутри, потому что «Калинин» и «Калинина» — это общие куски.

К тому же, Левенштейн не умеет работать с альтернативными названиями. А наш умеет.

Страница 404

Когда человек заходит на несуществующую страницу, обычно ему показывают какую-то скучную надпись вроде «Страница не найдена». Нам это всегда казалось невежливым. Человек уже потерялся, а его ещё и казённым языком встречают.

Потом мы заметили, что на многих хороших сайтах к 404 подходят не как к ошибке, а как к возможности. Причём самые удачные варианты — те, где страница ошибки продолжает тему сайта.

Самые крутые примеры:
https://www.fdoctor.ru/page404/
https://arzamas.academy/page404
https://www.hse.ru/page404 (каждый раз разная)

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

И вот в какой-то момент мы сидели и думали: а что связано с Коломной, с трамваями, с темой сайта, что может быть одновременно и ошибкой, и нормальным предметом?

Билет.

Трамвайный билет. На нём есть номер. А 404 — это же просто номер. Не «ошибка 404», а просто билет номер 000404. Никто даже не поймёт, что это техническая деталь, пока не присмотрится.

Тот самый случай, когда техническая деталь становится частью дизайна, а не торчит наружу.

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

Как раз и нужные цифры (0 и 4) здесь есть, не придётся ничего изобретать. Осталось только нафотошопить нужный билет.

И затем распечатать. Можно даже немного побольше по размеру, чем настоящий билет — всё равно ведь никто не проверит, а качество фото будет лучше. Чтобы создать впечатление настоящего билета, мы его немного порвали с краев и помяли. Без этого выглядит слишком чисто, сразу видно подделку.

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

Потом переставили лампу, чтобы тень была мягкой

Но тут вылезла другая проблема. Лист бумаги белый, но камера его не может снять таким же белым, как чисто-белый фон сайта. На сайте фон #FFFFFF, а на фотографии — сероватый.

Пришлось выбеливать фотографию в редакторе, чтобы фон стал идеально белым и слился с фоном страницы.

Вот что получилось в итоге.

Под билетом — надпись «Потерялись?». А ещё ниже — подпись. Она меняется каждый раз при обновлении страницы. Это просто маленькая игра. Кто-то обновит страницу пару раз и найдёт для себя подходящую.

Кстати, технически здесь нет никакой магии. Обычный .htaccess направляет все 404-е ошибки на специальный php-файл. Внутри — массив с фразами и функция shuffle().

Ну и если вы вдруг забредёте на несуществующую страницу — теперь знаете, какой билет вам выдадут.

https://kolomna-trams.ru/page404/

Трамвай будет через 5 минут

Посмотрите на скриншот.

Казалось бы, ничего необычного, да? Ближайший трамвай — через 5 минут.

А теперь вопрос со звёздочкой. Если мы сейчас находимся в другом часовом поясе, что это значит? Трамвай будет по-настоящему через 5 минут? Или это значит, что он должен был бы быть через 5 минут, если бы в Коломне было столько же времени, сколько в нашем часовом поясе?

Чтобы убрать все возможные недоразумения на этот счёт, в другом часовом поясе интерфейс отображает крупно время прибытия трамвая, а также текущее время в Коломне. Теперь всем понятно: сейчас в Коломне 09:26, трамвай будет в 09:31 по коломенскому времени, то есть через 5 минут. Никакой магии относительности.

Теперь о том, как это работает под капотом. Когда сервер собирает страницу, он встраивает в HTML своё текущее время. Браузер это время считывает и вычисляет разницу со своим. Если разница нулевая — значит, пользователь сидит в том же часовом поясе, и интерфейс просто показывает «через 5 минут».

Если разница не равна нулю, то браузер вычисляет разницу и отображает время с её учётом (то есть любой вызов функции Date.now() прибавляет ко времени эту разницу).

Дело закрыто.

Как работает построение маршрута

Есть на сайте одна простая фича — построить маршрут между двумя остановками.

Маршрут на схеме от «Площади двух революций» до «Весенней улицы». Попробуйте сами: https://kolomna-trams.ru/route/map/#from=0101&to=1202

Выбираешь остановку А, выбираешь остановку Б — получаешь список, где сесть и где пересесть.

На первый взгляд кажется: ну что там сложного? Взял граф, нашел кратчайший путь, вывел на экран. Но всё сложнее. Как должен выглядеть граф? Что считать вершинами? Какие веса дать рёбрам? И главное — в какой момент человеку нужно пересесть на другой трамвай?

Первое, что приходит в голову — сделать граф, где вершины — это остановки, а рёбра — перегоны между ними. Сеть в Коломне маленькая, поэтому для поиска пути мы можем использовать алгоритм Дейкстры.

Дейкстре нужен взвешенный граф. Логичнее всего в качестве веса взять реальное время в пути. Поэтому мы собрали данные по всем соседним остановкам: посмотрели расписание, сверились с онлайн-картами, посчитали время и сложили всё в базу.

Кстати, эта функция доступна и по API. Вот, например, сколько ехать от «Студенческой» до «Улицы Калинина» в секундах:

https://kolomna-trams.ru/api/shuttletime?from=0501aF&to=0502aF

{
    "ok": "true",
    "result": {
        "time": 120
    }
}

Как работает алгоритм Дейкстры. Присваиваем начальной точке графа (точке отправления) число 0, остальным — бесконечность.

Затем выбираем вершину с минимальным значением (на первом шаге — начальная точка, поскольку 0 < ∞) и смотрим, с какими вершинами она связана. Новое значение для каждой из вершин находится как значение текущей вершины + вес связи.

При этом, если случится такое, что старое значение вершины меньше, чем рассчитанное, то мы его не трогаем (это означает, что на одном из прошлых шагов был найден маршрут оптимальнее).

Как только все связи обработаны, вершина помечается как «рассмотренная» и далее не учитывается. Весь алгоритм повторяется, пока все вершины не станут «рассмотренными». На втором этапе изучается одна из вершин, значение для которой пересчитано на первом шаге.

Здесь как раз видно, что 4 не заменяет 0: до «Площади двух революций» можно добраться быстрее, чем за 4 минуты

На третьем шаге опять выбираем нерассмотренную вершину с наименьшим значением («Конькобежный центр»), на четвёртом шаге — «Пионерская улица».

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

Но пользователю не нужен просто список остановок. Ему нужны номера трамваев и места пересадок. В построенном маршруте, например, нет прямого рейса трамвая.

Сначала мы делаем просто: берем первое ребро нашего маршрута и смотрим, какие трамваи по нему едут. В нашем примере это 1, 3, 7 и 9.

Для второго ребра — снова смотрим. Пока наборы маршрутов совпадают — всё хорошо.

Но в какой-то момент наборы расходятся.

Если в новом наборе есть хоть один маршрут из старого, значит, мы можем продолжать ехать без пересадки.

В нашем примере рано или поздно наборы становятся пустыми. Это значит, что без пересадки не обойтись. Тогда мы отрезаем кусок от начала до последней остановки, где набор ещё не был пуст, и начинаем строить заново с того места, где маршруты разошлись.

Шаг 4. Алгоритм повторяется, пока мы не достигнем точки назначения.

В итоге получается маршрут из нескольких частей. Для каждой части есть список подходящих трамваев. Осталось только красиво их показать на карточке.

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

Отчего так? Дело в том, что в Коломне есть редкие маршруты, например № 7, который ходит раз в 40 минут. Алгоритм предлагает ехать на нём, но это неудобно. В то же время можно без ущерба смыслу маршрута выйти на несколько остановок раньше, зато иметь возможность ехать на трамвае № 3, который ходит каждые 10-12 минут (а вкупе с тем, что № 7 нам тоже всё ещё подходит, время ожидания на остановке ещё снижается).

Можно было бы, конечно, вычислять на каждом шаге ещё и среднее время ожидания трамвая и как-то его сравнивать с каким-то пороговым значением, однако здесь был использован более простой метод. Всего в Коломне таких «редких» маршрутов пять: № 6, 7, 8, 9 и 10. Мы просто сказали алгоритму: считай, что наборы, состоящие только из редких маршрутов, — это пустые наборы. Так алгоритм начинает искать альтернативы с более частыми трамваями.

Но не всё так просто. Вот такой маршрут будет построен неверно:

Алгоритм подумает, что можно без пересадок добраться на трамвае № 4, проехав четыре остановки. На самом деле нужно проехать две, выйти и сесть на тот же четвертый, но в обратную сторону.

Чтобы это починить, мы усложнили граф. Теперь вершина — это не просто остановка, а конкретная платформа (в одну или другую сторону). Между платформами одного остановочного пункта мы добавили ребро с временем в полминуты.

https://kolomna-trams.ru/api/shuttletime?from=0501aF&to=0501aB

{
    "ok": "true",
    "result": {
        "time": 30
    }
}

Разбиение маршрута на части проходит по той же схеме с одним лишь отличием: переходы между остановками автоматически «сбрасывают» набор рассматриваемых маршрутов.

Но и это ещё не всё. Мы научили алгоритм строить сложные маршруты, но теперь он ломается на простых.

Вместо того чтобы просто ехать на одной «семёрке», он выдавал маршрут с четырьмя пересадками, хотя на последнем участке всё равно был только седьмой маршрут, а значит, нет никакого смысла садиться на трамваи других маршрутов, нужно с самого начала ждать «семёрку», как бы редко она ни ходила.

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

Так все части по очереди склеятся друг с другом (начиная с конца), и в итоге останется один беспересадочный маршрут с одним возможным трамваем.

Поскольку все эти вычисления занимают мало времени, мы сделали так, чтобы скрипт работал прямо в браузере. Бэкенд тут вообще не участвует.

Отдельно мы сделали подсветку на схеме. Когда вы строите маршрут, схема «гаснет», и только нужные линии остаются яркими. Это оказалось несложно: мы разобрали векторную схему на части, записали их в память, а потом просто проходим по маршруту и показываем нужные куски.

И ещё одна мелочь. Какой-нибудь невнимательный человек может решить построить маршрут от одной остановки до неё же самой. Это не ошибка, но надо как-то намекнуть, что он делает что-то не так. Лепить модальное окно — это перебор. Поэтому мы просто сделали так, что название остановки слегка дёргается. Типа: «ты уже здесь, выбери другую».

Бонусом — кнопка «Обратный маршрут». В большинстве случаев обратный путь — это просто поехать тем же трамваем, но в другую сторону. Чтобы это было наглядно, мы придумали такую анимацию.

На этом всё. Получилось, что за простой с виду функцией стоит довольно много возни.