17 KiB
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) - Ревизия: 2 (2026-08-31) — по SPEC-REVIEW-396-r1 (High-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 |
cancel(true), цель коммитится |
уже сохраняется через settled |
:1401 _cancelModeTransition |
смена режима | структурная |
:1551 _commitSpace |
смена пространства | структурная |
:4188, :4216 _load |
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):
cancel(false)после частичного перехода → хук сохранения вызван с представленным состоянием (AC1); структурная отмена → не вызван (AC2).- Серия из шести нотчей с интервалом 8/16/33 мс → мировая точка под курсором неизменна до 1e-9 (AC3), итоговый зум равен 1.15⁶ (AC4).
- Clamp у границы: смещение только по ограниченной оси, вторая ось точна.
reducedMotion→ 0 rAF, один кадр, точный таргет (AC7).
Browser smoke (demo/smoke_smooth_zoom.mjs, дополнение):
- Колесо → pointerdown по плану до окончания перехода → перезагрузка карты → восстановленный зум равен показанному до клика (AC1 на реальном DOM).
- Смена пространства во время перехода → в
localStorageдля исходного пространства осталось прежнее значение (AC2).
Perf-контракт (demo/performance/*):
- Число вызовов пересборки 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 в новой редакции.- Скриншоты не меняются: визуал в статике идентичен.