mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
230 lines
17 KiB
Markdown
Executable File
230 lines
17 KiB
Markdown
Executable File
# ТЗ #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)
|
||
- Ревизия: 3 (2026-08-31) — по SPEC-REVIEW-396-r2 (Medium-1: имена функций в таблице)
|
||
|
||
## Сценарий
|
||
|
||
Хозяин дома смотрит план на планшете. Крутит колесо (или жмёт «+»), чтобы
|
||
разглядеть кухню, и сразу тыкает в лампу — не дожидаясь, пока картинка доедет.
|
||
Через минуту уходит на второй этаж и возвращается: масштаб откатился к тому,
|
||
что был до зума. Второй случай — трекпад: быстрая серия нотчей уводит из-под
|
||
пальца ровно ту точку, которую он держал.
|
||
|
||
## Что человек увидит до и после
|
||
|
||
**До**: (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` не вызывает, поэтому показанный масштаб остаётся незаписанным.
|
||
|
||
Полный перечень мест, где переход отменяется, и их классификация (сверено
|
||
`grep`'ом по `src/houseplan-card.ts` на HEAD, каждое прочитано):
|
||
|
||
| Место | Что это | Класс |
|
||
|---|---|---|
|
||
| `:6387` `_stagePointerDown` | **буквальный сценарий issue**: касание плана поверх собственного зума | **пользовательская — сохранять** |
|
||
| `:6283` `_zoomAt` | немедленный pinch | правки не требует: оба вызывающих контекста сразу зовут `_saveZoom()` |
|
||
| `:1267` `_startCameraTransition` | цель совпала с текущим состоянием (no-op) | сохранять нечего — зум уже равен сохранённому |
|
||
| `:1159` `_onMotionChange`, `:2363` `_pageVisibility` | `cancel(true)`, цель коммитится | уже сохраняется через `settled` |
|
||
| `:1401` `_cancelModeTransition` | смена режима | структурная |
|
||
| `:1551` `_commitSpace` | смена пространства | структурная |
|
||
| `:4188`, `:4216` `_adoptStructuralResponses` | adoption конфига и layout | структурная |
|
||
| `:6089` `_applyView` | программная установка вида | структурная |
|
||
| `:6206` resize-обработчик | новая геометрия сцены | структурная |
|
||
| `:6360` `_restoreZoom` | восстановление сохранённого зума | структурная (пишет то, что и читает) |
|
||
| `:2745` `_cameraTransition.dispose()` | `disconnectedCallback`, минуя обёртку | структурная |
|
||
|
||
Правка нужна ровно в одном месте пользовательского класса (`:6387`) плюс сам
|
||
механизм различения; остальные строки перечислены, чтобы граница была явной и
|
||
следующая правка не расширила её молча.
|
||
|
||
Воспроизведение (исполнением, фейковый клок): переход 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`, **`_stagePointerDown`**, 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**. После пользовательской отмены перехода (`_stagePointerDown` —
|
||
касание плана, с которого начинается pan/pinch/draw/drag/selection)
|
||
сохранённый зум пространства равен показанному на момент отмены.
|
||
Доказательство: юнит на контроллер + карту, сравнение сохранённого значения
|
||
с `presented`.
|
||
- **AC2**. Структурная отмена (смена пространства/режима, `_applyView`,
|
||
resize, adoption, `_restoreZoom`, disconnect — строки таблицы выше) не
|
||
записывает зум вовсе. Доказательство: тот же юнит, обратный случай —
|
||
счётчик записей не растёт. Отдельно: `_zoomAt` (pinch) остаётся без
|
||
изменений и продолжает сохранять через вызывающий контекст.
|
||
- **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 в новой редакции.
|
||
- Скриншоты не меняются: визуал в статике идентичен.
|