mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
docs: specify the camera transition fixes (#396)
User-Visible: no Issue: #396
This commit is contained in:
Executable
+206
@@ -0,0 +1,206 @@
|
||||
# ТЗ #396 — Плавная камера: сохранение прерванного зума, честный якорь, заморозка feather
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/396
|
||||
- Приоритет: P1, bug (регресс против v1.69.0); полный трек — класс A, три
|
||||
находки на одной поверхности (камера), плюс правка формулировок принятой
|
||||
спеки #82 (`docs/specs/082-smooth-zoom.md` §10, §13)
|
||||
- Ревизия: 1 (2026-08-31)
|
||||
|
||||
## Сценарий
|
||||
|
||||
Хозяин дома смотрит план на планшете. Крутит колесо (или жмёт «+»), чтобы
|
||||
разглядеть кухню, и сразу тыкает в лампу — не дожидаясь, пока картинка доедет.
|
||||
Через минуту уходит на второй этаж и возвращается: масштаб откатился к тому,
|
||||
что был до зума. Второй случай — трекпад: быстрая серия нотчей уводит из-под
|
||||
пальца ровно ту точку, которую он держал.
|
||||
|
||||
## Что человек увидит до и после
|
||||
|
||||
**До**: (1) зум, прерванный любым касанием плана, не запоминается — при
|
||||
возврате на пространство карта показывает старый масштаб; (2) при быстром зуме
|
||||
трекпадом точка под курсором уезжает на 10–16 px; (3) во время плавного зума
|
||||
свечение пересобирает размытие на каждом кадре.
|
||||
**После**: показанный масштаб и есть сохранённый; точка под курсором стоит на
|
||||
месте при любой частоте событий; свечение во время движения камеры ведёт себя
|
||||
как при pinch/pan.
|
||||
|
||||
## Проблема и контракты по пунктам
|
||||
|
||||
### (1) B1 — прерванный зум не сохраняется
|
||||
|
||||
`_saveZoom()` на пути перехода вызывается ровно из `_settleCameraTransition`
|
||||
(`src/houseplan-card.ts:1237`). До перехода на анимацию запись делали сами
|
||||
команды: `_onWheel`, `_stepZoom`, `_resetZoom` (`v1.69.0:6009,6016,6030`).
|
||||
`CameraTransitionController.cancel(false)` (`src/viewport-transition.ts:189`)
|
||||
`settled` не вызывает, поэтому семь мест отмены — `:1267`, `:1401`, `:1551`,
|
||||
`:4188`, `:4216`, `:6089`, `:6206` — оставляют показанный масштаб незаписанным.
|
||||
|
||||
Воспроизведение (исполнением, фейковый клок): переход 1.0 → 1.15 за 220 мс,
|
||||
обрыв на 120 мс → `settled` вызван 0 раз, `presented.zoom = 1.1413`.
|
||||
|
||||
**Корень — в спеке #82, а не только в коде.** §13 говорит «structural
|
||||
cancellation не сохраняет stale camera target», §11 — «pointerdown … фиксирует
|
||||
представленный кадр». Спека не различает два случая отмены, а они разные:
|
||||
|
||||
| Вид отмены | Что на экране | Что сохранять |
|
||||
|---|---|---|
|
||||
| **Пользовательская**: pointerdown по сцене, начало pan/pinch/draw/drag/selection поверх собственного зума | представленный кадр фиксируется и остаётся видимым | **сохранять представленное** — оно и есть текущее намерение |
|
||||
| **Структурная**: смена пространства/режима/проекции, resize, adoption конфига или layout, continuity recovery, viewport restore, disconnect | вид заменяется целиком другим контрактом | не сохранять ничего: цель устарела вместе с видом |
|
||||
|
||||
Контракт: **сохранённый зум пространства равен показанному после любой
|
||||
пользовательской отмены**. Структурная отмена по-прежнему не пишет ничего.
|
||||
|
||||
Требуется правка `docs/specs/082-smooth-zoom.md` §13: разделить два вида
|
||||
отмены явно, вместо одной строки про «stale target».
|
||||
|
||||
### (2) B2 — якорь считается от отстающего кадра
|
||||
|
||||
`_cameraTargetAt` (`src/houseplan-card.ts:6272`) передаёт в
|
||||
`cameraTargetAtAnchor` состояние `this._cameraState()` — представленный кадр.
|
||||
Пока tween не доиграл, он отстаёт от цели, и мировая точка под курсором
|
||||
вычисляется в другом viewport, чем тот, к которому строится новая цель.
|
||||
|
||||
Замер (6 нотчей шагом 1.15, сравнение «якорь от presented» против «якорь от
|
||||
целевого состояния»):
|
||||
|
||||
| интервал | смещение точки под курсором |
|
||||
|---|---|
|
||||
| 8 мс | 14,5 px |
|
||||
| 16 мс | 16,1 px |
|
||||
| 33 мс | 10,3 px |
|
||||
| 220 мс | 0,0 px |
|
||||
|
||||
Спека #82 §10 при этом требует обе несовместимые вещи сразу: п.3 — «world-point
|
||||
… в представленном viewport», абзац ниже — «anchor остаётся на месте с ошибкой
|
||||
не более 0.5 CSS px». Реализация выполнила п.3 и нарушила AC.
|
||||
|
||||
Контракт: **масштаб и мировая точка берутся из одного и того же состояния —
|
||||
цели текущего перехода** (при её отсутствии — из представленного, оно же
|
||||
текущее). Это ровно принцип §10 п.2, распространённый на якорь. Ошибка якоря
|
||||
без clamp — **0 px** (точное равенство мировой точки до плавающей погрешности
|
||||
1e-9), а не «не более 0.5».
|
||||
|
||||
Требуется правка `docs/specs/082-smooth-zoom.md` §10: п.3 переформулировать на
|
||||
«в целевом viewport running tween», порог заменить на точное равенство.
|
||||
|
||||
Немедленный `_zoomAt` (pinch) продолжает работать от представленного состояния:
|
||||
там tween заведомо отменён, представленное и есть текущее.
|
||||
|
||||
### (3) M2 — feather не заморожен во время перехода
|
||||
|
||||
`src/houseplan-card.ts:10842-10847`: `resolveGlowFeather(…, !this._pinchStart &&
|
||||
!this._panStart)`. Признак «камера движется» подменён признаками жеста, а новый
|
||||
tween ни одного из них не выставляет: `perUnit` меняется каждый кадр, регион
|
||||
размытия пересобирается на каждом.
|
||||
|
||||
Контракт: feather заморожен, **пока камера движется любым способом** — pinch,
|
||||
pan или анимированный переход.
|
||||
|
||||
## Скоуп / не-скоуп
|
||||
|
||||
**В скоупе**: `src/viewport-transition.ts`, камера-путь `src/houseplan-card.ts`
|
||||
(`_startCameraTransition`, `_cameraTargetAt`, `_onWheel`, `_stepZoom`,
|
||||
`_cancelCameraTransition`, glow-гейт), правка §10 и §13 в
|
||||
`docs/specs/082-smooth-zoom.md`, тесты и мутанты.
|
||||
|
||||
**Не в скоупе**: поведение pinch/pan persistence (свой контракт, §13 #82),
|
||||
длительности и кривые анимации, kiosk double-tap, изменение формата `LS_ZOOM`,
|
||||
editor zoom (не персистится по решению #82).
|
||||
|
||||
## UX
|
||||
|
||||
Видимых изменений в оформлении нет. Меняется только то, что показанный
|
||||
масштаб сохраняется, а точка под курсором не уезжает.
|
||||
|
||||
## Модель данных и миграция
|
||||
|
||||
Формат `LS_ZOOM` и warm viewport memo не меняются. Миграции нет.
|
||||
|
||||
## i18n
|
||||
|
||||
Новых строк нет.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- **AC1**. После пользовательской отмены перехода (pointerdown по сцене,
|
||||
начало pan/pinch/draw/drag/selection) сохранённый зум пространства равен
|
||||
показанному на момент отмены. Доказательство: юнит на контроллер + карту,
|
||||
сравнение сохранённого значения с `presented`.
|
||||
- **AC2**. Структурная отмена (смена пространства/режима, adoption, restore,
|
||||
disconnect) не записывает зум вовсе. Доказательство: тот же юнит, обратный
|
||||
случай — счётчик записей не растёт.
|
||||
- **AC3**. Серия из шести wheel-событий с интервалом 8 мс оставляет мировую
|
||||
точку под курсором на месте с точностью 1e-9 единиц плана (без clamp).
|
||||
Доказательство: юнит с фейковым клоком; при clamp смещение допускается
|
||||
только по ограниченной оси.
|
||||
- **AC4**. Накопление масштаба в серии не меняется: шесть нотчей дают тот же
|
||||
итоговый зум, что и сегодня (1.15⁶ с точностью 1e-12) — фикс якоря не
|
||||
трогает арифметику масштаба.
|
||||
- **AC5**. Во время анимированного перехода `resolveGlowFeather` вызывается с
|
||||
замороженным признаком: число пересборок региона размытия за один зум не
|
||||
больше, чем на pinch-пути.
|
||||
- **AC6**. `docs/specs/082-smooth-zoom.md` §10 и §13 приведены в соответствие
|
||||
с реализуемым поведением; ни один другой параграф не меняется.
|
||||
- **AC7**. Регресс не возвращается: reduced-motion по-прежнему даёт ноль
|
||||
запросов rAF и точный таргет, `dispose()` снимает rAF, анимационное
|
||||
состояние не попадает в конфиг.
|
||||
|
||||
## План автотестов
|
||||
|
||||
**Unit** (`test/viewport-transition.test.mjs`, `test/camera-persistence.test.mjs`):
|
||||
|
||||
1. `cancel(false)` после частичного перехода → хук сохранения вызван с
|
||||
представленным состоянием (AC1); структурная отмена → не вызван (AC2).
|
||||
2. Серия из шести нотчей с интервалом 8/16/33 мс → мировая точка под курсором
|
||||
неизменна до 1e-9 (AC3), итоговый зум равен 1.15⁶ (AC4).
|
||||
3. Clamp у границы: смещение только по ограниченной оси, вторая ось точна.
|
||||
4. `reducedMotion` → 0 rAF, один кадр, точный таргет (AC7).
|
||||
|
||||
**Browser smoke** (`demo/smoke_smooth_zoom.mjs`, дополнение):
|
||||
|
||||
5. Колесо → pointerdown по плану до окончания перехода → перезагрузка карты →
|
||||
восстановленный зум равен показанному до клика (AC1 на реальном DOM).
|
||||
6. Смена пространства во время перехода → в `localStorage` для исходного
|
||||
пространства осталось прежнее значение (AC2).
|
||||
|
||||
**Perf-контракт** (`demo/performance/*`):
|
||||
|
||||
7. Число вызовов пересборки feather за один анимированный зум не превышает
|
||||
значение pinch-пути (AC5).
|
||||
|
||||
**Мутанты** (`scripts/mutation-gate.mjs`):
|
||||
|
||||
- `camera-anchor-from-presented`: вернуть якорь на представленный кадр → юнит
|
||||
AC3 красный.
|
||||
- `camera-cancel-loses-zoom`: убрать сохранение при пользовательской отмене →
|
||||
юнит AC1 красный.
|
||||
- `glow-feather-thaws-during-camera`: снять признак движения камеры из гейта
|
||||
feather → перф-контракт AC5 красный.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Двойная запись зума.** Сохранение при отмене плюс сохранение в `settle`
|
||||
может дать две записи на один жест. Смягчение: запись идемпотентна по
|
||||
значению (тот же зум не переписывает storage — §11 #82 «no-op не переписывает
|
||||
localStorage»), контракт проверяется счётчиком записей в юните.
|
||||
- **Тонкая грань «пользовательская против структурной».** Ошибка
|
||||
классификации перевернёт поведение. Смягчение: классификация задаётся одним
|
||||
аргументом на вызове отмены (а не выводится из состояния), оба случая
|
||||
покрыты юнитами, список мест перечислен в спеке #82 §13.
|
||||
- **Изменение якоря затрагивает pinch.** Смягчение: `_zoomAt` явно оставлен на
|
||||
представленном состоянии, отдельный юнит фиксирует, что pinch-путь не
|
||||
изменился численно.
|
||||
|
||||
## Откат
|
||||
|
||||
Точечный: три изменения независимы. Откат якоря — вернуть `_cameraState()` в
|
||||
`_cameraTargetAt`; откат персиста — убрать сохранение из ветки отмены; откат
|
||||
feather — вернуть гейт на признаки жеста. Формат хранения не меняется, поэтому
|
||||
откат не оставляет следов в данных пользователя.
|
||||
|
||||
## Release-артефакты
|
||||
|
||||
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: один пункт про то, что
|
||||
прерванный зум запоминается и точка под курсором не уезжает (User-Visible).
|
||||
- `docs/specs/082-smooth-zoom.md`: §10 и §13 в новой редакции.
|
||||
- Скриншоты не меняются: визуал в статике идентичен.
|
||||
Reference in New Issue
Block a user