Стенд для замера срабатываний колеса мыши и тачпада. Отвечает на один вопрос: как сделать так, чтобы одинаковый физический жест давал одинаковое число срабатываний на любом устройстве.
Один HTML-файл, без зависимостей и сборки. Открывается двойным кликом.
Открой index.html в браузере:
open index.htmlНичего ставить и собирать не нужно: ни сборки, ни сервера, ни внешних запросов. Всё работает с file://.
У разных мышей разные контроллеры и разное преобразование поворота колеса в пиксели. Если вешать логику на пройденный путь в пикселях, то на медленной мыши 30° поворота не дадут ничего, а на чувствительной те же 30° дадут несколько срабатываний. Тачпад ломает картину окончательно — он шлёт десятки мелких событий вместо щелчков.
Прогрессия убирает эту зависимость: она смотрит не на путь, а на наличие ускорения в коротком окне. Величина события делится на автоопределяемый шаг устройства, умножается на текущую скорость и копится в заряде с экспоненциальным затуханием. Срабатывание — когда заряд перевалил порог.
Один и тот же физический жест (примерно оборот колеса за ~230 мс) на дефолтных параметрах:
| профиль | наивно | прогрессия |
|---|---|---|
| Мышь, 100 px/щелчок | 8 | 4 |
| Мышь, 114 px/щелчок | 7 | 3 |
| Мышь высокой чувствительности | 15 | 4 |
| Свободное колесо (MX Master) | 40 | 4 |
| Тачпад 120 Гц | 30 | 4 |
| Тачпад 240 Гц | 60 | 4 |
Firefox, deltaMode=1 |
8 | 4 |
| контроль: медленное кручение | 8 | 0 |
Разброс 1 против 53. Медленное вдумчивое кручение на том же пути в 800 px не срабатывает вообще.
Побочный выход — velocity, нормированная скорость в единицах в секунду. Её можно использовать для плавной
прокрутки таймлайна, чтобы не было рывков на быстрой мыши и бесконечного скролла на медленной.
1 · Записи реальных жестов. Пишет последовательность (Δpx, Δt) с твоего устройства. Каждая запись показывает
результат текущими параметрами и пересчитывается на каждое движение любого ползунка: сколько срабатываний даёт
прогрессия, сколько наивная обработка, и разбивка по жестам внутри записи. Когда число меняется, рядом появляется
▲ было 46 — видно направление и величину эффекта, ничего переигрывать не надо.
Кнопка ▶ дополнительно прокручивает запись в реальном времени: события подаются по их же интервалам, ровно как
при живом кручении, так что графики, вспышки и зелёные метки срабатываний ведут себя один в один. Перед стартом даётся
фора 1.2 с, чтобы успеть перевести взгляд на панели; в строке рядом идёт обратный отсчёт, потом проценты и живой счёт.
На графиках момент старта помечен вертикальным пунктиром старт. Повторное нажатие (■) останавливает.
Чипы в шапке (deltaMode, ось, шаг устройства, интервал) описывают живое устройство и при проигрывании записи
не трогаются — иначе характеристики файла залипали бы в них как свои. Шаг конкретной записи виден в её строке и в логе жестов.
Записи выгружаются в JSON — сними жест на медленной мыши, на быстрой, на тачпаде, и балансируй сразу по всем трём. Стоит первой секцией, чтобы кнопка записи была под рукой, пока крутишь колесо над панелями ниже.
2 · Наивная обработка. Shift + колесо, каждое wheel-событие = срабатывание (либо порог в пикселях —
вторая наивная стратегия, тоже не спасающая). Лог сырых событий: номер, дельта, интервал, ось, deltaMode,
накопленный путь, метка срабатывания.
3 · Прогрессия. Тот же ввод через описанный алгоритм. 15 параметров, живой график заряда с линией порога, кнопки экспорта готового класса и загрузки настроек обратно.
4 · Эталонные устройства. Синтетические профили из таблицы выше прогоняются текущими параметрами на каждое движение ползунка. Двигаешь «полураспад» — сразу видишь, разъезжается ли столбец. Метрика разброса и контрольные строки на медленное кручение.
- Наведи курсор на панель, зажми Shift, покрути колесо. Считает только панель под курсором. Галка «один жест → обе панели» кормит оба алгоритма одним жестом, когда нужно честное сравнение.
- Сделай одинаковый физический жест — например, ровно 3 щелчка колеса или один короткий флик по тачпаду.
- Смотри строку жеста в логе:
жест #3 · событий 30 · px 810 · срабатываний 4 · шаг 27 · пик v 96 · 232 мс. - Повтори на другом устройстве. Наивная панель даст разброс, прогрессия должна дать то же число.
- Кнопка «Скопировать замер» выгружает UA, параметры, лог жестов и записи — удобно сравнивать между машинами.
Кнопки «Скопировать класс (JS)» и «(TS)» генерируют WheelProgression с текущими значениями ползунков,
вшитыми в static defaults, с датой калибровки и UA устройства, на котором крутили.
const wheel = new WheelProgression();
element.addEventListener('wheel', (e) => {
if (!e.shiftKey) return;
e.preventDefault();
const { steps, direction, velocity, progress } = wheel.handleEvent(e);
for (let i = 0; i < steps; i++) nextFrame(direction); // дискретный выход
// timeline.scrollBy(velocity * direction * SCALE); // непрерывный выход
}, { passive: false });handleEvent сам разбирается с deltaMode и с тем, что Shift переносит дельту на ось X.
progress (0..1) — заполненность заряда, годится для индикатора «вот-вот сработает».
Сгенерированный класс — точный порт того, что крутится в стенде: он прогоняется по всем эталонным профилям и даёт те же цифры до единицы.
«Применить настройки…» открывает поле, куда можно вставить и раскатать на ползунки:
- голый JSON настроек —
{"threshold": 2.5, "halfLifeMs": 300, "release": "half"}, частичный тоже годится; - полный замер из «Скопировать замер» — подхватит и параметры прогрессии, и наивной панели;
- блок
static defaultsиз сгенерированного класса или весь файл класса целиком, с комментариями и кодом — ключи без кавычек, одинарные кавычки и висячие запятые разбираются наравне с JSON.
Значения проверяются: выходящие за диапазон ползунка зажимаются, нераспознанные и лишние ключи пропускаются, и всё это перечисляется в отчёте под кнопкой. Настройки, уехавшие в проект, всегда можно вернуть в стенд и продолжить крутить с того же места — круговой прогон идёт без потерь.
norm = min(|Δpx| / шаг_устройства, зажим) // снимаем разницу контроллеров и DPI
v = EMA(norm / Δt) // скорость в единицах/с
вклад = norm · (база + усиление · (v / v₀)^экспонента)
заряд = заряд · 0.5^(Δt / полураспад) + вклад // экспоненциальная утечка
Срабатывание при заряд ≥ порог. Шаг устройства — медиана последних 96 величин: у мыши это ~100 px,
у тачпада ~9 px, и обе приводятся к одной шкале сами.
| группа | параметр | что делает |
|---|---|---|
| Ядро | Порог заряда | сколько единиц нужно накопить; линейный множитель пути до срабатывания |
| Полураспад заряда | за сколько мс заряд падает вдвое; меньше — жёстче требование к ускорению | |
| Кулдаун | жёсткий пол во времени между срабатываниями | |
| После срабатывания | вычесть порог переносит остаток, обнулить теряет |
|
| Ускорение | База | вклад события при нулевой скорости; 0 — без ускорения не копится вообще |
| Вклад ускорения / Экспонента / Опорная скорость | форма прогрессии: насколько резко путь сокращается со скоростью | |
| Потолок усиления / Сглаживание скорости | зажим множителя и инерционность EMA | |
| Нормировка | Шаг устройства | авто (перцентиль), фиксированный, выкл |
| Перцентиль / Зажим шага | робастность автоопределения и потолок вклада одного события | |
| Жест | Пауза между жестами | тишина дольше — сброс состояния |
| Сброс при развороте / Зажим Δt | поведение при смене направления и верхняя граница интервала |
Прямого параметра «путь в пикселях» нет — в этом и смысл. Эффективный путь:
путь_px ≈ шаг_устройства × порог / (база + усиление·(v/v₀)^экспонента)
снизу ограниченный кулдауном: путь_px ≥ скорость_px_в_мс × кулдаун. На дефолтах и обычных скоростях
связывает именно кулдаун, а не порог — если кажется, что порог не влияет, дело в этом.
- Shift + колесо переносит дельту на ось X в Chrome и Firefox на всех платформах. Если читать только
deltaY, на реальном железе получишь нули. Стенд берёт доминирующую ось и показывает её в шапке. deltaModeбывает 1 (строки) и 2 (страницы) — в Firefox регулярно. Приводится к пикселям до нормировки.- Инерционный хвост тачпада: macOS после отрыва пальцев продолжает слать события с затухающими дельтами. Сейчас они попадают в тот же жест и могут дать лишнее срабатывание. Не обработано.
- Буфер обмена на
file://может быть недоступен в зависимости от браузера. Все кнопки копирования в этом случае показывают текст прямо на странице — забирай оттуда.
index.html весь стенд одним файлом: разметка, стили, алгоритм, эталоны, записи
README.md этот файл