diff --git a/demo/smoke_smooth_zoom.mjs b/demo/smoke_smooth_zoom.mjs index 9a9618c7..3423e5d0 100644 --- a/demo/smoke_smooth_zoom.mjs +++ b/demo/smoke_smooth_zoom.mjs @@ -200,6 +200,77 @@ const out = await page.evaluate(async () => { const resources = performance.getEntriesByType('resource') .map((entry) => new URL(entry.name).pathname); + // #396 идёт последним намеренно: проверки выше считают saveCount + // абсолютным значением, а пользовательская отмена теперь тоже пишет. + // #396: interrupted zoom, structural cancellation, fast wheel anchor. + const spaceOf = () => card._space; + const storedZoom = () => { + try { + return (JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}') || {})[spaceOf()]; + } catch { return undefined; } + }; + const wheelAt = (x, y, deltaY) => stage().dispatchEvent(new WheelEvent('wheel', { + clientX: x, clientY: y, deltaY, bubbles: true, cancelable: true, composed: true, + })); + const pointerDownAt = (x, y) => stage().dispatchEvent(new PointerEvent('pointerdown', { + clientX: x, clientY: y, pointerId: 1, isPrimary: true, button: 0, + bubbles: true, cancelable: true, composed: true, + })); + + directBaseline(1); + await settle(); + const stageRect = () => stage().getBoundingClientRect(); + // (1) wheel, then touch the plan mid-flight: the shown zoom must be stored. + wheelAt(stageRect().left + 600, stageRect().top + 400, -100); + await frame(); + await frame(); + const interruptedShown0 = card._zoom; + pointerDownAt(stageRect().left + 200, stageRect().top + 200); + await card.updateComplete; + const interruptedShown = card._zoom; + const interruptedStored = storedZoom(); + const interruptedFroze = !card._cameraTransition.active + && close(interruptedShown, interruptedShown0, 1e-9); + + // (2) structural cancellation writes nothing. + directBaseline(1); + await settle(); + const structuralStoredBefore = storedZoom(); + wheelAt(stageRect().left + 600, stageRect().top + 400, -100); + await frame(); + await frame(); + const structuralSavesBefore = saveCount; + card._cancelCameraTransition(false); + await card.updateComplete; + const structuralSaves = saveCount - structuralSavesBefore; + const structuralStored = storedZoom(); + + // (3) six notches, one animation frame apart: the anchor must stay put. + directBaseline(1); + await settle(); + const anchorScreen = { x: stageRect().left + 600, y: stageRect().top + 400 }; + const worldUnder = () => { + const box = card._view; + const box2 = { x: box.x, y: box.y, w: box.w, h: box.h }; + const r = stageRect(); + return { + x: box2.x + ((anchorScreen.x - r.left) / r.width) * box2.w, + y: box2.y + ((anchorScreen.y - r.top) / r.height) * box2.h, + }; + }; + const anchorBefore = worldUnder(); + for (let i = 0; i < 6; i++) { + wheelAt(anchorScreen.x, anchorScreen.y, -100); + await frame(); + } + await settle(); + const anchorAfter = worldUnder(); + const anchorDriftUnits = Math.hypot(anchorAfter.x - anchorBefore.x, + anchorAfter.y - anchorBefore.y); + const anchorDriftPx = anchorDriftUnits * (stageRect().width / card._view.w); + directBaseline(1); + await settle(); + return { buttonHasIntermediateFrame: buttonSamples.some((sample) => sample.zoom > 1.001 && sample.zoom < buttonExpected.zoom - 0.001), @@ -241,7 +312,18 @@ const out = await page.evaluate(async () => { glowAndStructuralFrameStayStable: JSON.stringify(glowAfter) === JSON.stringify(glowBefore), coldViewDoesNotLoadEditorRuntime: resources.every((path) => !/houseplan-editor-runtime-/.test(path)), - diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter }, + // #396 AC1: touching the plan freezes the animated frame — and the frozen + // frame is what the user sees, so it must be what gets persisted. + userCancelPersistsTheShownZoom: interruptedShown > 1.001 + && close(interruptedStored, interruptedShown, 1e-6), + // #396 AC2: a structural cancellation writes nothing. + structuralCancelPersistsNothing: structuralSaves === 0 + && close(structuralStored, structuralStoredBefore, 1e-9), + // #396 AC3: the anchor under the pointer survives a fast wheel series. + fastWheelKeepsTheAnchor: anchorDriftPx < 0.5, + diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter, + interruptedShown, interruptedStored, interruptedFroze, structuralSaves, + structuralStored, structuralStoredBefore, anchorDriftPx }, }; }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 75c54142..aaf5d06f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,10 @@ instead of the browser prompt. Device, draft, plan and space deletion plus lock opening share clear consequences, safe Cancel/X/Escape behaviour and stale-target protection ([#32](https://github.com/Matysh/houseplan-card/issues/32)). +- Zooming feels honest again: the zoom you see when you tap the plan + mid-animation is the zoom that gets remembered, and a fast trackpad series + keeps the point under the cursor exactly where it was + ([#396](https://github.com/Matysh/houseplan-card/issues/396)). ## v1.70.0-beta.1 — 2026-08-30 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 071346e7..59485b0f 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -13,6 +13,10 @@ файла плана и пространства, а также открытие замка получили понятные последствия, безопасные «Отмена»/X/`Esc` и защиту от уже изменившейся цели ([#32](https://github.com/Matysh/houseplan-card/issues/32)). +- Зум снова честный: масштаб, который вы видите в момент касания плана, + и запоминается, а быстрая серия колеса или трекпада удерживает точку под + курсором ровно на месте + ([#396](https://github.com/Matysh/houseplan-card/issues/396)). ## v1.70.0-beta.1 — 2026-08-30 diff --git a/docs/specs/082-smooth-zoom.md b/docs/specs/082-smooth-zoom.md index 83517c24..1bc5e425 100644 --- a/docs/specs/082-smooth-zoom.md +++ b/docs/specs/082-smooth-zoom.md @@ -169,14 +169,21 @@ targets и создаёт финальный commit jump. Каждое новое wheel-событие: -1. берёт реально представленный camera state running tween; +1. берёт реально представленный camera state running tween как точку старта + анимации; 2. накапливает zoom от предыдущего target, а не от запаздывающего кадра; -3. вычисляет world-point под актуальным pointer в представленном viewport; +3. вычисляет world-point под актуальным pointer **в целевом viewport running + tween** — из того же состояния, из которого взят zoom в п.2 (при отсутствии + running tween целевое и представленное совпадают). Правка #396: чтение + точки из представленного кадра уводило anchor на 14–16 CSS px при интервале + между событиями 8–33 мс, то есть на обычном трекпаде, и противоречило + порогу ниже; 4. строит новый target так, чтобы этот point остался под pointer; 5. заменяет transition без queue и второго RAF; 6. допускает немедленный разворот направления. -Без clamp anchor остаётся на месте с ошибкой не более 0.5 CSS px. При clamp +Без clamp anchor остаётся на месте точно — расхождение мировой точки не +превышает 1e-9 единиц плана (плавающая погрешность), а не «0.5 CSS px». При clamp смещение допускается только на ограниченной оси. `deltaMode` нормализуется так, чтобы line/page wheel не создавал многосекундную очередь; текущий один event = один factor остаётся совместимым. @@ -224,7 +231,16 @@ graph, wall model, projection cache или Glow source set. Нельзя вре - Retargetable wheel stream также даёт одну финальную запись. - Editor zoom не записывается в View intent. - Формат `LS_ZOOM` и warm viewport memo не меняется. -- Structural cancellation не сохраняет stale camera target. +- Отмена перехода бывает двух видов, и они пишут по-разному (уточнено #396; + прежняя единственная строка про «stale target» их не различала, из-за чего + прерванный зум терялся): + - **пользовательская** — `_stagePointerDown`, то есть касание плана поверх + собственного зума. Представленный кадр замораживается и остаётся на + экране, значит он и есть текущее намерение: **сохраняется**; + - **структурная** — смена пространства/режима/проекции, `_applyView`, + resize, adoption конфига или layout, `_restoreZoom`, continuity recovery, + disconnect. Вид заменяется целиком другим контрактом: **не сохраняется + ничего**, цель устарела вместе с видом. - Immediate pinch/pan используют существующий gesture persistence contract; его отдельная оптимизация не входит в #82. - No-op не переписывает localStorage. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 89da9373..ade69499 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -746,6 +746,39 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'camera-cancel-loses-zoom', + guard: 'node demo/smoke_smooth_zoom.mjs', + because: 'the frame frozen by touching the plan is what the user sees; not ' + + 'persisting it is the v1.69.0 regression #396 B1 closes', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' this._cancelCameraTransition(false, true);', + replace: ' this._cancelCameraTransition(false);', + }], + }, + { + id: 'camera-anchor-from-presented', + guard: 'node demo/smoke_smooth_zoom.mjs', + because: 'reading the anchor from the lagging frame walks the point under ' + + 'the pointer 17 px away in a fast wheel series (#396 B2)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' const anchorFrom = (animated && this._cameraTransition.target)', + replace: ' const anchorFrom = (false && this._cameraTransition.target)', + }], + }, + { + id: 'glow-feather-thaws-during-camera', + guard: 'node --test test/golden-matrix.test.mjs', + because: 'the feather freeze must know about the animated transition, not ' + + 'only about pinch and pan (#396 M2)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' && !this._cameraTransition.active;', + replace: ' && true;', + }], + }, { id: 'error-code-dropped-from-contract', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 9584758d..3ca1dae1 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -1245,9 +1245,22 @@ export class HouseplanCard extends LitElement { this.requestUpdate(); } - private _cancelCameraTransition(commitTarget = false): void { + /** Cancel a running camera transition. + * + * #396: two cancellations look alike and mean opposite things. A STRUCTURAL + * one (space/mode change, adopt, resize, restore, disconnect) replaces the + * view wholesale — its stale target must not be written anywhere. A USER + * one (touching the plan on top of one's own zoom) freezes the presented + * frame and leaves it on screen: that frame IS the current intent, and + * before #82 the zoom commands persisted it synchronously. Keeping both on + * one branch is what lost the interrupted zoom. */ + private _cancelCameraTransition(commitTarget = false, keepPresented = false): void { + const presentedZoom = keepPresented && this._cameraTransition.active + ? this._cameraTransition.presented?.zoom + : undefined; this._cameraTransition.cancel(commitTarget); this._cameraTransitionFit = null; + if (presentedZoom !== undefined) this._saveZoom(); } /** A discrete camera command may follow a rapid mode click. Finish that @@ -6286,14 +6299,21 @@ export class HouseplanCard extends LitElement { sx: number, sy: number, newZoom: number, + animated = false, ): { target: CameraState; fit: ModeViewBox } | null { const stage = this._stageEl; if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return null; const vb = this._baseVb(); const fit = fitView(vb, this._stageAspect()); const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom)); + // #396 AC3: the world point under the pointer is read from the SAME state + // the zoom is accumulated from — the running target. Reading it from the + // lagging presented frame made a fast trackpad series walk the anchor + // 14-16 CSS px away, while spec #82 §10 promises it stays put. + const anchorFrom = (animated && this._cameraTransition.target) + || this._cameraState(); const target = cameraTargetAtAnchor( - this._cameraState(), z, fit, + anchorFrom, z, fit, stage.clientWidth, stage.clientHeight, sx, sy, ); if (!target) return null; @@ -6323,7 +6343,7 @@ export class HouseplanCard extends LitElement { const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom; const result = this._cameraTargetAt( - ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor, + ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor, true, ); if (result) this._startCameraTransition( result.target, result.fit, 'wheel', CAMERA_WHEEL_MS, @@ -6339,6 +6359,7 @@ export class HouseplanCard extends LitElement { stage.clientWidth / 2, stage.clientHeight / 2, baseZoom * (delta > 0 ? 1.4 : 1 / 1.4), + true, ); if (result) this._startCameraTransition( result.target, result.fit, 'button', CAMERA_BUTTON_MS, @@ -6407,7 +6428,9 @@ export class HouseplanCard extends LitElement { } private _stagePointerDown(ev: PointerEvent): void { - this._cancelCameraTransition(false); + // The gesture that starts here freezes the animated frame and keeps it on + // screen — so the shown zoom becomes the saved one (#396 AC1). + this._cancelCameraTransition(false, true); if (this._vacFit) return; // no pan/swipe while fitting the robot map // The shared secondary controller owns palette dismissal in window // capture, including the matching synthetic click. Do not duplicate that @@ -10868,8 +10891,13 @@ export class HouseplanCard extends LitElement { const perUnit = this._stageEl?.clientWidth && view.w ? this._stageEl.clientWidth / view.w : 1; + // #396: the freeze must key on "the camera is moving", not on the two + // gesture flags. An animated transition sets neither, so every tween frame + // rebuilt the blur region — the very cost this gate was added to avoid. + const cameraStill = !this._pinchStart && !this._panStart + && !this._cameraTransition.active; const feather = resolveGlowFeather( - this._glowRuntimeState, perUnit, !this._pinchStart && !this._panStart, + this._glowRuntimeState, perUnit, cameraStill, ); return renderGlowPools({ spots, diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index d24a9fee..25bf9e38 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -799,8 +799,15 @@ test('a light source paints exactly one region: the floor it can see', () => { assert.equal((glow.match(/ 0 \? perUnit : 1\)/); + // #396: the freeze keys on "the camera is still", not on the two gesture + // flags. An animated transition sets neither, so every tween frame rebuilt + // the blur region — the cost this gate exists to avoid. Both the predicate + // and its use are pinned: a predicate that stops reading the transition is + // the regression, and so is a call that stops reading the predicate. assert.match(source, - /resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, !this\._pinchStart && !this\._panStart/); + /const cameraStill = !this\._pinchStart && !this\._panStart\s*\n\s*&& !this\._cameraTransition\.active;/); + assert.match(source, + /resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, cameraStill,/); assert.match(glow, /filter=\$\{input\.featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/, 'the expensive whole-layer filter must be bypassed during a viewport gesture/transition'); diff --git a/test/viewport-transition.test.mjs b/test/viewport-transition.test.mjs index 13289494..d5e978a3 100644 --- a/test/viewport-transition.test.mjs +++ b/test/viewport-transition.test.mjs @@ -136,3 +136,96 @@ test('invalid input and missing RAF settle safely without a loop', () => { assert.equal(controller.active, false); assert.equal(sameCameraState(to, { zoom: 2, viewBox: { ...to.viewBox } }), true); }); + +// --- #396 --------------------------------------------------------------- +// Три находки аудита v1.70.0-beta.1 на одном пути камеры. Проверяется то же, +// что и в бою: серия колеса поверх незакончившегося перехода и обрыв этого +// перехода касанием плана. + +const WHEEL = 1.15; +const FIT = { x: 0, y: 0, w: 1000, h: 1000 }; +const STAGE = { w: 800, h: 800 }; +const ANCHOR = { x: 600, y: 400 }; + +/** Мировая точка под указателем для данного viewport. */ +const worldUnderPointer = (viewBox) => ({ + x: viewBox.x + (ANCHOR.x / STAGE.w) * viewBox.w, + y: viewBox.y + (ANCHOR.y / STAGE.h) * viewBox.h, +}); + +/** Одна серия из `count` нотчей с интервалом `gapMs`, как её ведёт карта: + * масштаб копится от цели, а якорь берётся из состояния `anchorFrom`. */ +function wheelSeries({ count, gapMs, anchorFromTarget }) { + const harness = fakeClock(); + let presented = camera(1, ...Object.values(FIT)); + let target = null; + const controller = new CameraTransitionController( + { frame: (state) => { presented = state.presented; }, settled: () => {} }, + harness.clock, + ); + for (let i = 0; i < count; i++) { + const base = target ?? presented; + const from = anchorFromTarget ? (target ?? presented) : presented; + const next = cameraTargetAtAnchor( + from, base.zoom * WHEEL, FIT, STAGE.w, STAGE.h, ANCHOR.x, ANCHOR.y, + ); + controller.start(presented, next, 'wheel', 220); + target = next; + harness.step((i + 1) * gapMs); + } + return { target, presented }; +} + +test('#396 AC3: a fast wheel series keeps the world point under the pointer', () => { + const start = worldUnderPointer(FIT); + for (const gapMs of [8, 16, 33]) { + const { target } = wheelSeries({ count: 6, gapMs, anchorFromTarget: true }); + const moved = worldUnderPointer(target.viewBox); + const drift = Math.hypot(moved.x - start.x, moved.y - start.y); + assert.ok(drift < 1e-9, + `anchor must not walk at ${gapMs} ms between notches, drifted ${drift}`); + } + // Контроль: якорь от отстающего кадра — это и есть дефект B2, он обязан + // давать заметный увод, иначе тест выше ничего не доказывает. + const { target } = wheelSeries({ count: 6, gapMs: 16, anchorFromTarget: false }); + const moved = worldUnderPointer(target.viewBox); + const lagging = Math.hypot(moved.x - start.x, moved.y - start.y); + assert.ok(lagging > 1, `the lagging-frame anchor should drift, got ${lagging}`); +}); + +test('#396 AC4: fixing the anchor does not change zoom accumulation', () => { + for (const anchorFromTarget of [true, false]) { + const { target } = wheelSeries({ count: 6, gapMs: 8, anchorFromTarget }); + assert.ok(Math.abs(target.zoom - WHEEL ** 6) < 1e-12, + `six notches must accumulate to 1.15^6, got ${target.zoom}`); + } +}); + +test('#396 AC1/AC2: only a user cancellation may persist the shown frame', () => { + // Контроллер сам ничего не сохраняет — решение принимает вызывающий по + // `presented`. Здесь фиксируется то, на что он опирается: после обрыва + // показанный кадр известен и лежит между началом и целью. + const harness = fakeClock(); + let presented = null; + let settledCalls = 0; + const controller = new CameraTransitionController( + { frame: (state) => { presented = state.presented; }, + settled: () => { settledCalls++; } }, + harness.clock, + ); + controller.start(camera(1, 0, 0, 1000, 1000), camera(1.15, 0, 0, 870, 870), + 'wheel', 220); + harness.step(120); + const shown = presented.zoom; + assert.ok(shown > 1 && shown < 1.15, 'the frame is mid-flight'); + assert.equal(controller.active, true); + controller.cancel(false); + assert.equal(settledCalls, 0, 'a frozen cancellation does not settle'); + assert.equal(controller.active, false); + assert.equal(controller.presented, null, 'state is dropped, the frame is the caller\'s'); + // И обратный случай: коммит цели проходит через settled, как и раньше. + controller.start(camera(1, 0, 0, 1000, 1000), camera(1.15, 0, 0, 870, 870), + 'wheel', 220); + harness.step(400); + assert.equal(settledCalls, 1, 'a completed transition settles exactly once'); +});