Repeat/interval analysis: auto-find repeated segments in one ride, table + last/first verdict
This commit is contained in:
parent
6dfe262801
commit
7717a73129
6 changed files with 203 additions and 0 deletions
|
|
@ -151,6 +151,7 @@
|
|||
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
|
||||
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
|
||||
- Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save)
|
||||
- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=3 входов), один эталонный проход, разметка проходов по возрастанию позиции (порт `tools/track/pers.py:passes`); возвращает `{found,segLength,passCount,passes}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица заездов + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - из `avgPower` (новое поле возврата `GPS.computeStats`)
|
||||
- Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter
|
||||
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
|
||||
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
|
||||
- **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется.
|
||||
- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, один эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость нормированная на длину участка, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет.
|
||||
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
|
||||
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
|
||||
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
|
||||
|
|
|
|||
48
docs/session-logs/2026-09-17_repeat-intervals.md
Normal file
48
docs/session-logs/2026-09-17_repeat-intervals.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Сессия 2026-09-17 - автоматический разбор повторов внутри поездки (интервалы)
|
||||
|
||||
## Контекст
|
||||
Заказ из `docs/gps-model-findings.md` (раздел «БЭКЛОГ: автоматический разбор повторов»).
|
||||
Пользователь катает интервалы: один подъём 5 раз за поездку, один трек, без стоп/старт.
|
||||
Нужно: повторы находятся сами по самоподобию, таблица заездов и число «последний/первый».
|
||||
Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md`
|
||||
(в docs/plans не копировал - план короткий и одноразовый; здесь ссылка).
|
||||
|
||||
## Сделано
|
||||
- `GPS.detectRepeats(points, opts)` (gps.js) - порт `tools/track/pers.py`, но эталонный
|
||||
проход находится сам:
|
||||
1. сетка ~10м (формула из `wind_by_passes`/`heightmap.py`);
|
||||
2. горячие ячейки = >= minPasses (3) входов (заход = смена ключа ячейки);
|
||||
3. эталон = ОДИН чистый проход: от первой горячей ячейки, добавляя новые, до первого
|
||||
возврата к более ранней (склейка кругов давала абсурд - см. баги);
|
||||
4. проходы = порт `passes()`: позиция в seq растёт от старта (допуск назад 3, гэп 25),
|
||||
покрытие >= 80%; старт-гвард `pos<=15%` не даёт спуску открыть заезд;
|
||||
5. нормировка: сравнение по `L_seq / T_прохода` (снимает 5% разброса длины GPS).
|
||||
Направление без явной проверки bearing: seq хранит первую (подъёмную) позицию ячейки,
|
||||
спуск роняет позицию и закрывает заезд.
|
||||
- `avgPower` в возврат `GPS.computeStats` (накопление `power*dt` в вело-ветке / время
|
||||
педалирования) - для колонки «Вт».
|
||||
- UI: секция-аккордеон «Разбор повторов» в `showSessionDetail` (только вело), таблица
|
||||
(№/время/км-ч норм/каденс/Вт), вердикт последний/первый (зелёный >=100%, жёлтый >=90%,
|
||||
красный <90%), подсветка участка на карте (бирюза `#00e5ff`). CSS в styles.css.
|
||||
|
||||
## Баги по пути (и фиксы)
|
||||
- Эталон как «самый длинный горячий ран» склеивал 5 кругов -> segLength 4406м и заезды по
|
||||
9с (норм. скорость 1762 км/ч). Фикс: эталон = один проход до первого возврата.
|
||||
- Следующая точка в ТОЙ ЖЕ ячейке считалась «возвратом» и сразу обрывала seq (seq=1).
|
||||
Фикс: `c===seq[last]` -> продолжить, не обрывать.
|
||||
|
||||
## Проверка
|
||||
- Node-харнесс на `tools/track/baldav.db` (реальные данные):
|
||||
- 11 сен (3214 тчк): участок 1577м, **5 заездов**, норм. скорости 15.0/17.1/17.8/17.9/17.5,
|
||||
последний/первый 117%.
|
||||
- 17 сен (3079 тчк): участок 1630м, **5 заездов**, 16.9/15.8/17.3/16.4/17.7, 105%.
|
||||
Совпадает с находками («11 и 17 сентября, по 5 повторов»).
|
||||
- Контроль: 5 обычных вело-поездок (8-23 км) + 2 короткие -> «повторов не найдено» (0 ложных).
|
||||
- Десктоп (Xvfb): временно подсажена сессия 17 сен в дев-БД (бэкап + точечное удаление
|
||||
после, БД байт-в-байт восстановлена). Секция рисуется, аккордеон работает, таблица 5 строк
|
||||
совпадает с харнессом, вердикт зелёный 105%, участок подсвечен бирюзой на карте. Скрин.
|
||||
- Далее: сборка APK + проверка на телефоне (реальные интервалы там же).
|
||||
|
||||
## Не в этой задаче
|
||||
Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET);
|
||||
п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы).
|
||||
|
|
@ -1542,6 +1542,18 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Repeat / interval analysis table + verdict */
|
||||
.repeats-head { font-size: 12px; color: var(--on-bg); opacity: 0.8; margin-bottom: 8px; }
|
||||
.repeats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.repeats-tbl th, .repeats-tbl td { padding: 5px 6px; text-align: right; border-bottom: 1px solid var(--border); }
|
||||
.repeats-tbl th:first-child, .repeats-tbl td:first-child { text-align: left; }
|
||||
.repeats-tbl th { color: var(--primary); font-weight: 600; }
|
||||
.repeats-verdict { margin-top: 10px; padding: 10px; border-radius: var(--radius); text-align: center; line-height: 1.4; }
|
||||
.repeats-verdict .rv-num { font-size: 22px; font-weight: 700; }
|
||||
.repeats-verdict.ok { background: rgba(46, 160, 67, 0.18); color: #4caf50; }
|
||||
.repeats-verdict.warn { background: rgba(255, 179, 0, 0.16); color: #ffb300; }
|
||||
.repeats-verdict.bad { background: rgba(207, 102, 121, 0.18); color: #cf6679; }
|
||||
|
||||
.sd-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,88 @@ GPS.detectWalk = function(points, opts) {
|
|||
return out;
|
||||
};
|
||||
|
||||
// Auto-detect repeated segments within ONE ride (interval training: the same climb
|
||||
// ridden N times, one track, no stop/restart). The repeated corridor is found by
|
||||
// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run
|
||||
// where position along a reference segment rises monotonically), but the reference
|
||||
// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride
|
||||
// revisits), not given as road_points ids.
|
||||
// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }.
|
||||
// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST
|
||||
// (uphill) position, so coasting back down makes position fall and ends the pass, and
|
||||
// the "must start near seq beginning" guard stops a descent from opening a new pass.
|
||||
GPS.detectRepeats = function(points, opts) {
|
||||
opts = opts || {};
|
||||
let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap)
|
||||
let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long
|
||||
let minPasses = opts.minPasses ?? 3; // and ridden at least this many times
|
||||
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment
|
||||
let n = points ? points.length : 0;
|
||||
if (n < 20) return { found: false };
|
||||
|
||||
// Grid cell key per point.
|
||||
let key = new Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
let la = points[i].lat, lo = points[i].lon;
|
||||
if (la == null || lo == null) { key[i] = null; continue; }
|
||||
let ky = Math.floor(la * 111320 / cell);
|
||||
let kx = Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
|
||||
key[i] = ky + "," + kx;
|
||||
}
|
||||
|
||||
// Entries per cell: count each time the track enters a cell (key changes into it).
|
||||
// A corridor ridden 5x (up+down the same road) racks up ~10 entries; approach/return
|
||||
// cells get 1. So >= minPasses entries marks the repeated corridor.
|
||||
let visits = new Map();
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (key[i] == null) continue;
|
||||
if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1);
|
||||
}
|
||||
let isHot = (k) => k != null && (visits.get(k) || 0) >= minPasses;
|
||||
|
||||
// Reference segment = ONE clean traversal of the corridor: start at the first hot
|
||||
// cell, add each new hot cell in order, and STOP at the first revisit (that means the
|
||||
// rider turned back / started the next lap). Gluing several laps into the reference
|
||||
// (a long hot run) inflates its length and scrambles the order — measured, gives
|
||||
// absurd 9-second "passes" over a 4.4 km "segment". One traversal fixes both.
|
||||
let s0 = 0;
|
||||
while (s0 < n && !isHot(key[s0])) s0++;
|
||||
if (s0 >= n) return { found: false };
|
||||
let seq = [], order = new Map(), s1 = s0;
|
||||
for (let k = s0, gap = 0; k < n; k++) {
|
||||
let c = key[k];
|
||||
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
|
||||
gap = 0;
|
||||
if (seq.length && c === seq[seq.length - 1]) { s1 = k; continue; } // still in the same cell
|
||||
if (order.has(c)) break; // revisit of an earlier cell → first traversal complete
|
||||
order.set(c, seq.length); seq.push(c); s1 = k;
|
||||
}
|
||||
let segLength = 0;
|
||||
for (let k = s0 + 1; k <= s1; k++)
|
||||
segLength += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
|
||||
if (seq.length < 3 || segLength < minLenM) return { found: false };
|
||||
|
||||
// Passes: scan the ride; a pass is a run where position along seq rises from the
|
||||
// start (tolerating 3-cell backsteps and gaps), covering >= minFrac of the segment.
|
||||
let pos = key.map(k => (order.has(k) ? order.get(k) : null));
|
||||
let passes = [];
|
||||
let p = 0;
|
||||
while (p < n) {
|
||||
if (pos[p] == null || pos[p] > seq.length * 0.15) { p++; continue; }
|
||||
let q = p, lo = pos[p], hi = pos[p], gap = 0;
|
||||
while (q < n - 1) {
|
||||
q++;
|
||||
if (pos[q] == null) { gap++; if (gap > 25) break; continue; }
|
||||
if (pos[q] < hi - 3) break; // went backward — pass ended (e.g. coasting down)
|
||||
gap = 0; hi = Math.max(hi, pos[q]);
|
||||
}
|
||||
if ((hi - lo) >= seq.length * minFrac) passes.push({ startIdx: p, endIdx: q });
|
||||
p = q + 1;
|
||||
}
|
||||
if (passes.length < minPasses) return { found: false };
|
||||
return { found: true, segLength: Math.round(segLength), passCount: passes.length, passes };
|
||||
};
|
||||
|
||||
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
|
||||
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
|
||||
// Only touch the value if it's still the old default; respect a user-set threshold.
|
||||
|
|
@ -246,6 +328,9 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
|
||||
let totalKcal = 0;
|
||||
const basalPerMin = (1.0 * 3.5 * wKg) / 200;
|
||||
// Mechanical work while pedalling, for avgPower output (used by the repeat/interval
|
||||
// analysis to compare passes). Averaged over pedalling time, like tools/track pers.py.
|
||||
let mechWorkJ = 0, powerSec = 0;
|
||||
|
||||
// Cadence tracking
|
||||
let totalPedalRevs = 0;
|
||||
|
|
@ -407,6 +492,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
// 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest
|
||||
// BMR already covers — the empirically measured cost.
|
||||
const physicsKcal = Math.max(0, power) * dt / efficiency / 4184;
|
||||
mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power
|
||||
const vKmh = v * 3.6;
|
||||
if (vKmh >= 5) {
|
||||
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
|
||||
|
|
@ -474,6 +560,8 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
const avgCadence = (hasGearData && pedalingSeconds > 0)
|
||||
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
|
||||
: null;
|
||||
// Average mechanical power (W) over pedalling time, null if never pedalled.
|
||||
const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
|
||||
|
|
@ -570,6 +658,7 @@ GPS.computeStats = function(points, weightKg, opts) {
|
|||
kcal: Math.round(totalKcal),
|
||||
duration: Math.round(duration),
|
||||
avgCadence,
|
||||
avgPower,
|
||||
segments,
|
||||
walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs)
|
||||
walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance)
|
||||
|
|
|
|||
|
|
@ -5231,6 +5231,12 @@ async function showSessionDetail(sessionId) {
|
|||
</div>
|
||||
</details>` : ""}
|
||||
|
||||
${session.activity_type === 'bike' ? `
|
||||
<details class="sd-section sd-accordion" id="sd-repeats">
|
||||
<summary class="sd-title sd-acc-title">Разбор повторов</summary>
|
||||
<div id="sd-repeats-body" class="repeats-body">Считаю...</div>
|
||||
</details>` : ""}
|
||||
|
||||
<div class="sd-section" id="sd-goals-section" style="display:none">
|
||||
<div class="sd-title">Цели</div>
|
||||
<div id="sd-goals-list"></div>
|
||||
|
|
@ -5573,6 +5579,52 @@ async function showSessionDetail(sessionId) {
|
|||
if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км";
|
||||
}
|
||||
|
||||
// Interval / repeat analysis: the same segment ridden several times in one ride
|
||||
// (intervals). Passes found by self-similarity (GPS.detectRepeats), per-pass stats
|
||||
// via computeStats, plus the last/first verdict; the corridor is highlighted on the
|
||||
// map. Length-normalised speed (segLength / pass time) cancels GPS length noise so
|
||||
// passes compare fairly. Bike only.
|
||||
if (session.activity_type === "bike") {
|
||||
(async () => {
|
||||
let body = document.querySelector("#sd-repeats-body");
|
||||
if (!body) return;
|
||||
let rep = GPS.detectRepeats(points);
|
||||
if (!rep.found) { body.textContent = "Повторов не найдено (обычная поездка)."; return; }
|
||||
let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||||
let weightKg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
|
||||
let opts = {
|
||||
activityType: "bike", bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type,
|
||||
chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth,
|
||||
wheelDiameterInch: bike?.wheel_diameter_inch, gearCount: bike?.gear_count,
|
||||
};
|
||||
let rows = rep.passes.map((p, i) => {
|
||||
let seg = points.slice(p.startIdx, p.endIdx + 1);
|
||||
let st = GPS.computeStats(seg, weightKg, opts);
|
||||
let T = Math.max(1, (new Date(seg[seg.length - 1].ts) - new Date(seg[0].ts)) / 1000);
|
||||
return { i: i + 1, T, normV: rep.segLength / T * 3.6, cad: st.avgCadence, pw: st.avgPower };
|
||||
});
|
||||
let ratio = rows[rows.length - 1].normV / rows[0].normV, pct = Math.round(ratio * 100);
|
||||
let verdict, vClass;
|
||||
if (ratio >= 1.0) { verdict = "последний не медленнее первого - можно повторить завтра"; vClass = "ok"; }
|
||||
else if (ratio >= 0.90) { verdict = "нормальная тяжёлая тренировка"; vClass = "warn"; }
|
||||
else { verdict = "просел больше 10% - два дня спокойно"; vClass = "bad"; }
|
||||
let segStr = rep.segLength >= 1000 ? (rep.segLength / 1000).toFixed(2) + " км" : rep.segLength + " м";
|
||||
body.innerHTML =
|
||||
`<div class="repeats-head">Участок ${segStr}, заездов ${rep.passCount} (скорость нормирована на длину участка)</div>` +
|
||||
`<table class="repeats-tbl"><thead><tr><th>№</th><th>время</th><th>км/ч</th><th>каденс</th><th>Вт</th></tr></thead><tbody>` +
|
||||
rows.map(r => `<tr><td>${r.i}</td><td>${GPS.formatDuration(Math.round(r.T))}</td><td>${r.normV.toFixed(1)}</td><td>${r.cad ?? "-"}</td><td>${r.pw ?? "-"}</td></tr>`).join("") +
|
||||
`</tbody></table>` +
|
||||
`<div class="repeats-verdict ${vClass}"><span class="rv-num">${pct}%</span> последний / первый<br>${verdict}</div>`;
|
||||
// Highlight the repeated corridor on the map (every pass draws the same line).
|
||||
try {
|
||||
for (let p of rep.passes) {
|
||||
let seg = points.slice(p.startIdx, p.endIdx + 1).map(q => [q.lat, q.lon]);
|
||||
if (seg.length >= 2) L.polyline(seg, { color: "#00e5ff", weight: 5, opacity: 0.85 }).addTo(map);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
|
||||
// Invisible but tappable hit-targets on top of track
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
let p = points[i];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue