From 84fc273eea35648b43b14fa352293be6b59ef54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wr=C3=B3blewski?= Date: Sat, 12 Sep 2026 06:42:10 +0200 Subject: [PATCH] R360: motion that eases, a drag that coasts, and the owner's switch (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A click on the ring ran at one pace from the first frame to the last and stopped dead on arrival; a drag stopped the instant the hand left the picture. Both have a shape now. A travel gathers pace, runs fastest halfway and settles onto its frame; a drag thrown rather than put down keeps turning at the hand's speed and slows to a stop. The coast is not a second mechanism. `coastAfterDrag` reads the speed of the throw, works out how far constant slowing carries it and how long that takes, and hands those frames to the same travel a ring click uses — so a grab, a key, a frame count that changed under the hook and the #161 landing timer hold a coast exactly as they hold a travel, none of them taught anything new. Nor is its curve a look chosen by eye: 2t − t² IS where constant slowing puts a thing, the same motion the distance was measured with, so the frames and the time cannot drift apart. The owner gets a sixth parameter to turn both off, per work, in works.r360_params — so no migration. It is stored the other way about from the cue points: absent means ON, and only an owner turning it off is ever written down, so every work saved before today glides too. A visitor whose system asks for less motion gets neither, whatever the owner chose. The direction control and the new one are the same two-way toggle with different words in it, so they are one component now. The feel itself — how fast a coast sheds speed, and so how long it runs and how far it carries — is one constant in lib/r360/orbit.ts, to be tried on a phone with Dawid. Co-Authored-By: Claude Opus 5 --- SPEC.md | 2 +- e2e/db/r360.spec.ts | 28 +- e2e/db/works.spec.ts | 18 ++ messages/en.json | 4 + messages/pl.json | 4 + .../(public)/[handle]/r360-params.tsx | 109 +++++-- .../[locale]/(public)/[handle]/work-form.tsx | 2 +- src/components/ui/use-orbit.ts | 139 +++++++-- src/db/schema.ts | 15 +- src/lib/r360/cues.test.ts | 30 ++ src/lib/r360/frame-set-shared.ts | 11 +- src/lib/r360/orbit.test.ts | 287 +++++++++++++++++- src/lib/r360/orbit.ts | 218 ++++++++++++- src/lib/r360/ring.ts | 12 +- tasks/plan.md | 12 +- 15 files changed, 822 insertions(+), 69 deletions(-) diff --git a/SPEC.md b/SPEC.md index efc1e46..61c6895 100644 --- a/SPEC.md +++ b/SPEC.md @@ -56,7 +56,7 @@ it (Dawid, 06.09.2026). | A10 | Seven transactional e-mails (verification, re-verification, reset, password-change confirmation, address change ×2, handle change) via Scaleway TEM. SPF, DKIM and DMARC configured before the first real message goes out. Zero marketing e-mail. | | A11 | Homepage for signed-out visitors: full-screen photo + entry to sign-up/sign-in. Visual design — open (§12); the MVP ships a style-consistent placeholder. | | A12 | Profile sections (decision of 08.09.2026, #72), all optional: cover photo (as A4, WebP variants 1600/480 px wide, aspect kept); headline (≤ 220 characters); places — up to 8, each a TERYT name (a voivodeship, county, commune or any locality: the GUS registers are in the database and searched on the server, #87) or free text (≤ 80); bio (≤ 1500, line breaks kept). Works: up to 10 per profile; name required (≤ 120), investor and developer (≤ 120), 1–3 photos (as A4, one of them the main photo) — or 0–3 when the work carries an R360, whose start frame is then the main picture (decision of 09.09.2026); an R360 orbit, made from a zip the owner's browser reads on their own machine and never sends (A13). Edited in place on the owner's page; public the moment it is saved. | -| A13 | R360 (decision of 09.09.2026, #68; the zip stopped being uploaded on 10.09.2026, #120): a work's orbit zip is opened in the owner's browser, **on their own machine, and never leaves it** — the browser validates it, then reduces every frame to WebP at 1600 and 800 px wide and uploads only those; the server never sees the archive at all and verifies only the count, the sizes and a sample of headers of the frames that arrived. Frame order = the number in the file name (the last run of digits, a contiguous range, no gaps; the contract on #64). The visitor sees one frame at a time and orbits by dragging the picture — relative, discrete, wrapping past the last frame — or on an elliptical ring dial that shows the position, travels on click and fills as frames load. Five parameters per work, set in edit mode: frame count (detected, 2–360), direction, frames per picture width (1–N), start frame, ring flattening (1 = a circle). Cue points (#107, decided with Dawid on 11.09.2026): up to 12 named frames per work, each a marker on the ring — its name beside it only while it is pointed at (the mouse on it or on its button; a click or a tap goes there) — and a button in a row under the picture that goes there in one press. A phone's public page shows no ring at all, only the buttons; the redesign will keep the ring for the enlarged view on the profile page too. A new zip with the same frame count as the orbit last in the form keeps its parameters and cue points; another count starts from the defaults, and a zip of another count picked by mistake and taken out untouched does not wipe what the one before had. Every stage of the owner's flow has a progress bar. | +| A13 | R360 (decision of 09.09.2026, #68; the zip stopped being uploaded on 10.09.2026, #120): a work's orbit zip is opened in the owner's browser, **on their own machine, and never leaves it** — the browser validates it, then reduces every frame to WebP at 1600 and 800 px wide and uploads only those; the server never sees the archive at all and verifies only the count, the sizes and a sample of headers of the frames that arrived. Frame order = the number in the file name (the last run of digits, a contiguous range, no gaps; the contract on #64). The visitor sees one frame at a time and orbits by dragging the picture — relative, discrete, wrapping past the last frame — or on an elliptical ring dial that shows the position, travels on click and fills as frames load. Six parameters per work, set in edit mode: frame count (detected, 2–360), direction, frames per picture width (1–N), start frame, ring flattening (1 = a circle), motion. Cue points (#107, decided with Dawid on 11.09.2026): up to 12 named frames per work, each a marker on the ring — its name beside it only while it is pointed at (the mouse on it or on its button; a click or a tap goes there) — and a button in a row under the picture that goes there in one press. Motion (#153, wanted by Dawid on 11.09.2026): a click on the ring gathers pace and settles onto its frame, and an orbit thrown with the hand keeps turning and slows to a stop — the owner's switch, per work, ON unless they turn it off, so a work saved before the switch existed glides too. A visitor whose system asks for less motion gets neither, whatever the owner chose. A phone's public page shows no ring at all, only the buttons; the redesign will keep the ring for the enlarged view on the profile page too. A new zip with the same frame count as the orbit last in the form keeps its parameters and cue points; another count starts from the defaults, and a zip of another count picked by mistake and taken out untouched does not wipe what the one before had. Every stage of the owner's flow has a progress bar. | --- diff --git a/e2e/db/r360.spec.ts b/e2e/db/r360.spec.ts index 1cce651..c95fbf1 100644 --- a/e2e/db/r360.spec.ts +++ b/e2e/db/r360.spec.ts @@ -61,7 +61,20 @@ test.afterAll(async () => { await visitor.context().close(); }); -async function dragAcross(page: Page, fraction: number) { +/** + * A drag across the picture. `thrown` is the difference #153 made: a + * hand still moving when it lets go throws the orbit on, and Playwright + * lands its moves within a millisecond of one another — a speed no hand + * reaches. So unless a throw is what the test is about, the hand comes to + * rest first; without that pause every drag here would coast on past the + * frame the assertions are about, at a distance decided by how busy the + * machine was. + */ +async function dragAcross( + page: Page, + fraction: number, + { thrown = false }: { thrown?: boolean } = {}, +) { const viewer = page.getByTestId("orbit-viewer").first(); await viewer.scrollIntoViewIfNeeded(); const box = await viewer.boundingBox(); @@ -71,6 +84,7 @@ async function dragAcross(page: Page, fraction: number) { await page.mouse.move(from, y); await page.mouse.down(); await page.mouse.move(from + box.width * fraction, y, { steps: 4 }); + if (!thrown) await page.waitForTimeout(150); await page.mouse.up(); } @@ -125,7 +139,7 @@ test("a drag by half the width at k = 2 turns one frame; past the last frame it await expectNoAxeViolations(visitor, test.info(), "public-r360-card"); }); -test("a tap on the ring's centre falls through to the picture, and a reduced-motion visitor jumps rather than travels (#106)", async () => { +test("a tap on the ring's centre falls through to the picture; a reduced-motion visitor jumps rather than travels (#106) and a throw does not coast (#153)", async () => { const still = await visitor.context().browser()!.newContext({ locale: "pl-PL", reducedMotion: "reduce", @@ -149,6 +163,16 @@ test("a tap on the ring's centre falls through to the picture, and a reduced-mot box.y + box.height / 2, ); await expect(viewer).toHaveAttribute("data-frame", "4"); + // #153: a drag let go of while still moving. For this visitor it must + // not coast — a jump is no substitute for one, so the orbit stops on + // the frame the hand left it on. Asserted as stillness past the longest + // a coast could ever run, so nothing here depends on how the feel is + // tuned; a landing frame would. + await dragAcross(page, 0.5, { thrown: true }); + const landed = await viewer.getAttribute("data-frame"); + expect(landed).not.toBeNull(); + await page.waitForTimeout(1400); + await expect(viewer).toHaveAttribute("data-frame", landed!); await still.close(); }); diff --git a/e2e/db/works.spec.ts b/e2e/db/works.spec.ts index 879bc85..5349ce3 100644 --- a/e2e/db/works.spec.ts +++ b/e2e/db/works.spec.ts @@ -270,6 +270,11 @@ test("a new work: the photo goes through the upload chain as a work, then the fo await page.mouse.move(box.x + box.width * 0.75, box.y + box.height / 2, { steps: 4, }); + // #153: the hand comes to rest before it lets go. Playwright's moves + // land within a millisecond of one another, and a release read as a + // throw would coast the preview on while the start frame is being set + // from it. + await page.waitForTimeout(150); await page.mouse.up(); await expect(viewer).toHaveAttribute("data-frame", "2"); await page.getByRole("button", { name: "Użyj tej klatki" }).click(); @@ -294,6 +299,19 @@ test("a new work: the photo goes through the upload chain as a work, then the fo expect(body?.subarray(8, 12).toString()).toBe("WEBP"); } + // #153: the motion switch starts ON, because that is what a work saved + // before it existed reads as. Turned off and on again it must leave + // NOTHING behind — the parameters saved below carry no `glide` key at + // all, which is precisely how ON is stored. + const glideOn = page.getByTestId("work-r360-glide-on"); + const glideOff = page.getByTestId("work-r360-glide-off"); + await expect(glideOn).toHaveAttribute("aria-pressed", "true"); + await glideOff.click(); + await expect(glideOff).toHaveAttribute("aria-pressed", "true"); + await expect(glideOn).toHaveAttribute("aria-pressed", "false"); + await glideOn.click(); + await expect(glideOn).toHaveAttribute("aria-pressed", "true"); + await page.getByLabel("Inwestor").fill("Archicom S.A."); await page.getByRole("button", { name: "Zapisz realizację" }).click(); await expect.poll(() => created.length).toBe(1); diff --git a/messages/en.json b/messages/en.json index 440005c..739d856 100644 --- a/messages/en.json +++ b/messages/en.json @@ -561,6 +561,10 @@ "paramStartFrame": "Start frame", "useThisFrame": "Use this frame", "paramFlattening": "Ring flattening", + "paramGlide": "Motion", + "glideOn": "Eased", + "glideOff": "Constant", + "glideHint": "Eased: a click on the ring gathers pace and settles onto its frame, and an orbit thrown with the hand keeps turning and slows to a stop. Constant is one pace from beginning to end. A visitor whose system asks for less motion gets neither — the orbit simply arrives.", "flatteningCircle": "Circle", "refused": { "not_a_zip": "This is not a zip archive.", diff --git a/messages/pl.json b/messages/pl.json index a2b358a..863e74f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -561,6 +561,10 @@ "paramStartFrame": "Klatka startowa", "useThisFrame": "Użyj tej klatki", "paramFlattening": "Spłaszczenie pierścienia", + "paramGlide": "Ruch", + "glideOn": "Płynny", + "glideOff": "Jednostajny", + "glideHint": "Płynny: kliknięcie w pierścień nabiera tempa i wyhamowuje na klatce, a obrót rzucony ręką kręci się dalej i zwalnia do zatrzymania. Jednostajny to jedno tempo od początku do końca. Gość, którego system prosi o mniej ruchu, nie dostanie żadnego z nich — obrót po prostu staje na miejscu.", "flatteningCircle": "Okrąg", "refused": { "not_a_zip": "To nie jest archiwum zip.", diff --git a/src/app/[locale]/(public)/[handle]/r360-params.tsx b/src/app/[locale]/(public)/[handle]/r360-params.tsx index 73030f1..22d4adf 100644 --- a/src/app/[locale]/(public)/[handle]/r360-params.tsx +++ b/src/app/[locale]/(public)/[handle]/r360-params.tsx @@ -11,13 +11,15 @@ import { type R360Cue, type R360Params, } from "@/lib/r360/frame-set-shared"; +import { glides } from "@/lib/r360/orbit"; -// #103: the owner's four parameters of an R360 set, under the preview in -// the work form (the fifth, the frame count, is the set's, shown read-only +// #103: the owner's five parameters of an R360 set, under the preview in +// the work form (the sixth, the frame count, is the set's, shown read-only // beside the preview). The direction is a two-way toggle, frames per // picture width a slider from 1 to N, the start frame is set from the // frame in view, the ring flattening a slider from 0.15 to 1 with a -// "circle" button. #107 adds the cue points below them. +// "circle" button, and the motion (#153) a two-way toggle like the +// direction. #107 adds the cue points below them. export function R360ParamControls({ params, @@ -34,27 +36,16 @@ export function R360ParamControls({ const t = useTranslations("Works.form.r360"); return (
-
- - {t("paramDirection")} - -
- {([1, -1] as const).map((direction) => { - const active = params.direction === direction; - return ( - - ); - })} -
-
+ ({ + key: String(direction), + label: t(direction === 1 ? "directionForward" : "directionReverse"), + active: params.direction === direction, + pick: () => onChange({ direction }), + }))} + /> + {/* #153: what the visitor's orbit does — a travel that eases in and + out of its frame, a drag that coasts on after the hand. Only OFF + is written into the parameters: absent is on, so a work saved + before the switch existed glides like the rest. */} + ({ + key: String(on), + label: t(on ? "glideOn" : "glideOff"), + active: glides(params) === on, + pick: () => onChange({ glide: on ? undefined : false }), + testId: `work-r360-glide-${on ? "on" : "off"}`, + }))} + /> void; + testId?: string; + }[]; + disabled: boolean; + /** A line under the buttons saying what the choice does. */ + hint?: string; +}) { + const hintId = useId(); + return ( +
+ {name} +
+ {choices.map((choice) => ( + + ))} +
+ {hint && ( +

+ {hint} +

+ )} +
+ ); +} + /** A parameter's name and its value as the owner has it. */ function ParamLabel({ name, diff --git a/src/app/[locale]/(public)/[handle]/work-form.tsx b/src/app/[locale]/(public)/[handle]/work-form.tsx index 3dc22fa..46c6533 100644 --- a/src/app/[locale]/(public)/[handle]/work-form.tsx +++ b/src/app/[locale]/(public)/[handle]/work-form.tsx @@ -986,7 +986,7 @@ export function WorkForm({ commitLocalFrames(null); } - // #103: the owner's four parameters, on the set the work will name. + // #103: the owner's five parameters, on the set the work will name. function setParams(change: Partial) { commitR360((current) => current diff --git a/src/components/ui/use-orbit.ts b/src/components/ui/use-orbit.ts index 88df2b1..280580c 100644 --- a/src/components/ui/use-orbit.ts +++ b/src/components/ui/use-orbit.ts @@ -2,11 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { + COAST_SAMPLES_KEPT, + coastOnRelease, frameAfterDrag, frameAfterKey, + framesAlong, + glides, + travelDuration, travelStop, wrapFrame, + type DragSample, type OrbitParams, + type TravelCurve, } from "@/lib/r360/orbit"; // #103/#104 (the decisions on #68): the hand on an orbit. A drag on the @@ -16,12 +23,15 @@ import { // the same way (#104). A travel (#106: a click on the ring) moves frame by // frame along a path over a bounded time, and any hand that takes hold — // a grab, a key — ends it. The arithmetic is lib/r360/orbit.ts. +// +// #153: the motion eases, on a work whose owner has left it to. A +// travel gathers pace and settles onto its frame; a drag thrown rather +// than put down coasts on and slows to a stop. Reduced motion still +// wins over both — a travel jumps, a release stops dead — and so does a +// hand: a grab or a key ends a coast exactly as it ends a travel, +// because a coast IS a travel, along the frames the throw would carry. const DRAG_SLOP_PX = 6; -/** A travel's pace, and the bounds that keep a long one from dragging on. */ -const TRAVEL_MS_PER_FRAME = 28; -const TRAVEL_MIN_MS = 250; -const TRAVEL_MAX_MS = 1200; /** * How long after a travel's own time is up its landing waits for the * animation to have done the job itself (#161). Wide enough that a busy @@ -30,6 +40,25 @@ const TRAVEL_MAX_MS = 1200; */ const TRAVEL_LANDING_AFTER_MS = 200; +function reducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** + * #153: the clock a drag is measured on. `event.timeStamp` is when the + * pointer actually moved, not when the handler got round to running — + * and on an orbit page those come apart: decoding frames blocks the main + * thread, the moves queued behind it then drain in one task, and + * `performance.now()` would read a whole swipe as having happened at + * once. Every reading of one gesture comes from here, so a browser whose + * stamps sit on another origin is still self-consistent; one that hands + * back no time at all leaves a span of zero, which `dragSpeed` reads as + * no speed — the safe way to be wrong. + */ +function clockOf(event: React.PointerEvent): number { + return event.timeStamp; +} + export interface Orbit { /** The frame in view, 1..N. */ frame: number; @@ -37,7 +66,9 @@ export interface Orbit { /** * Moves along `path` (the frames on the way, the destination last) over * a time proportional to its length, within bounds; reduced motion - * jumps. A grab or a key on the way ends it where it is. + * jumps. A grab or a key on the way ends it where it is. #153: eased + * in and out of its frame on an orbit that glides, at a constant pace + * on one whose owner turned that off. */ travelAlong: (path: readonly number[]) => void; cancelTravel: () => void; @@ -61,7 +92,12 @@ export function useOrbit( onFrameChange?: (frame: number) => void; } = {}, ): Orbit { - const { frameCount } = params; + const { frameCount, framesPerWidth, direction } = params; + // #153: read once, and as a plain boolean — the parameters arrive as a + // fresh object on some renders (a form with no set yet builds its + // defaults inline), and a callback keyed on the object itself would be + // rebuilt with every one of them. + const glide = glides(params); const [frame, setFrameState] = useState(() => wrapFrame(options.initialFrame ?? params.startFrame, frameCount), ); @@ -72,6 +108,9 @@ export function useOrbit( null, ); const current = useRef(frame); + // #153: where the pointer has lately been, for the speed a release + // coasts at. Kept short, and thrown away with every new grab. + const samples = useRef([]); const onFrameChange = useRef(options.onFrameChange); useEffect(() => { onFrameChange.current = options.onFrameChange; @@ -112,25 +151,23 @@ export function useOrbit( [cancelTravel, place], ); - const travelAlong = useCallback( - (path: readonly number[]) => { + /** + * #153: the motion itself — the frames of `path` spread over `duration` + * along `curve`, and the landing that does not depend on the animation + * (#161). A click on the ring and a drag thrown both end up here; they + * differ only in where the path and the time come from. + */ + const run = useCallback( + (path: readonly number[], duration: number, curve: TravelCurve) => { cancelTravel(); - if (path.length === 0) return; const destination = path[path.length - 1]; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - place(destination); - return; - } - const duration = Math.min( - TRAVEL_MAX_MS, - Math.max(TRAVEL_MIN_MS, path.length * TRAVEL_MS_PER_FRAME), - ); const started = performance.now(); const step = (now: number) => { const { frame: on, arrived } = travelStop( path, now - started, duration, + curve, ); // Settled before the frame is placed, never after: placing tells the // consumer where the orbit is, and a consumer that takes hold there @@ -158,6 +195,19 @@ export function useOrbit( [cancelTravel, place], ); + const travelAlong = useCallback( + (path: readonly number[]) => { + cancelTravel(); + if (path.length === 0) return; + if (reducedMotion()) { + place(path[path.length - 1]); + return; + } + run(path, travelDuration(path.length), glide ? "eased" : "steady"); + }, + [cancelTravel, glide, place, run], + ); + // A frame count that changed under the hook (a new archive in the same // form) keeps the frame within it, and ends a travel planned for the // old one. @@ -176,6 +226,12 @@ export function useOrbit( x: event.clientX, width: box.width, }; + // #153: the readings of the drag before this one are not this + // drag's. The press itself is not one of them either — it is where + // the finger landed, not motion, and counting it would measure a + // speed across the slop below and throw the orbit on a tap that + // wobbled. + samples.current = []; event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }, @@ -190,19 +246,52 @@ export function useOrbit( // A diagonal swipe sends a few moves before the browser claims the // vertical pan: a little slop keeps the orbit from jittering a frame. if (Math.abs(deltaX) < DRAG_SLOP_PX) return; + // #153: read past the slop, never within it. A tap whose finger + // jitters a few pixels quickly has a speed like any other motion, + // but it has not turned the orbit — and it must not throw it. + samples.current = [ + ...samples.current, + { x: event.clientX, t: clockOf(event) }, + ].slice(-COAST_SAMPLES_KEPT); place(frameAfterDrag(from.frame, deltaX, from.width, params)); }, [params, place], ); - const release = useCallback((event: React.PointerEvent) => { - if (!anchor.current) return; - anchor.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - setDragging(false); - }, []); + const release = useCallback( + (event: React.PointerEvent) => { + const from = anchor.current; + if (!from) return; + anchor.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + setDragging(false); + // #153: a hand still moving throws the orbit on. What decides that + // is lib/r360/orbit.ts, where it can be put to the test — this is + // only the reading of the clock and of the event. A pointer + // cancelled or taken away is not a release the visitor made, which + // is the same way the ring tells a click from a drag. + const coast = coastOnRelease( + { + samples: samples.current, + lift: { x: event.clientX, t: clockOf(event) }, + width: from.width, + lifted: event.type === "pointerup", + glide, + reducedMotion: reducedMotion(), + }, + { framesPerWidth, direction }, + ); + if (!coast) return; + run( + framesAlong(current.current, coast.turn, frameCount), + coast.ms, + "slowing", + ); + }, + [direction, frameCount, framesPerWidth, glide, run], + ); const onKeyDown = useCallback( (event: React.KeyboardEvent) => { diff --git a/src/db/schema.ts b/src/db/schema.ts index 53ba8b0..2145863 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -277,11 +277,11 @@ export const works = pgTable( developer: text("developer"), // #102 (A13): the frame set the owner's browser derived from a zip on // their own disk — the prefix `u//r360//` the frames sit - // under (32 hex digits minted at presign) — and the five viewer - // parameters (#68): frame count, direction, frames per picture width, - // start frame, ring flattening. Both or neither: a set without - // parameters cannot be shown, parameters without a set describe - // nothing. + // under (32 hex digits minted at presign) — and the six viewer + // parameters: frame count, direction, frames per picture width, start + // frame and ring flattening (#68), and whether the orbit glides + // (#153). Both or neither: a set without parameters cannot be shown, + // parameters without a set describe nothing. // // #120: there is no archive column any more. The zip never leaves the // owner's machine, so the work names its frames and nothing else. @@ -477,6 +477,11 @@ export interface R360ParamsRow { flattening: number; /** #107: labelled frames; absent on a work saved before them. */ cues?: { frame: number; label: string }[]; + /** + * #153: whether the orbit eases and coasts. Absent is ON — the field + * arrived after works were saved — so only `false` is ever written. + */ + glide?: boolean; } // #30: a staged upload is bytes that already exist in the bucket but have no diff --git a/src/lib/r360/cues.test.ts b/src/lib/r360/cues.test.ts index 5612036..ade3b05 100644 --- a/src/lib/r360/cues.test.ts +++ b/src/lib/r360/cues.test.ts @@ -79,6 +79,30 @@ describe("cues in the parameters", () => { }); }); +// #153: the switch is stored the other way about from the cue points — +// its absence is the feature being ON, so only an owner turning it off +// is ever written into a work. +describe("the glide in the parameters (#153)", () => { + it("reads a work saved before the switch as one that glides", () => { + const parsed = r360ParamsSchema.safeParse(defaultR360Params(4)); + expect(parsed.success).toBe(true); + expect(parsed.data?.glide).toBeUndefined(); + }); + + it("takes the owner turning it off, and refuses anything that is not a yes or a no", () => { + const off = r360ParamsSchema.safeParse({ + ...defaultR360Params(4), + glide: false, + }); + expect(off.success).toBe(true); + expect(off.data?.glide).toBe(false); + expect( + r360ParamsSchema.safeParse({ ...defaultR360Params(4), glide: "off" }) + .success, + ).toBe(false); + }); +}); + describe("isDefaultR360Params", () => { it("is true for the defaults of any count, and false once anything of the owner's is in them", () => { expect(isDefaultR360Params(defaultR360Params(4))).toBe(true); @@ -89,6 +113,7 @@ describe("isDefaultR360Params", () => { { startFrame: 3 }, { flattening: 0.3 }, { cues: [{ frame: 2, label: "Taras" }] }, + { glide: false }, ]) { expect( isDefaultR360Params({ ...defaultR360Params(4), ...change }), @@ -99,6 +124,11 @@ describe("isDefaultR360Params", () => { expect(isDefaultR360Params({ ...defaultR360Params(4), cues: [] })).toBe( true, ); + // #153: the glide left ON is the default, written down or not — it is + // nothing of the owner's to carry over to the next zip. + expect(isDefaultR360Params({ ...defaultR360Params(4), glide: true })).toBe( + true, + ); }); }); diff --git a/src/lib/r360/frame-set-shared.ts b/src/lib/r360/frame-set-shared.ts index 061c2d3..6c71e9f 100644 --- a/src/lib/r360/frame-set-shared.ts +++ b/src/lib/r360/frame-set-shared.ts @@ -132,9 +132,14 @@ export type R360Cue = z.infer; /** * The viewer parameters (#68), as stored in works.r360_params. The frame - * count is detected, not chosen; the other four are the owner's, and so + * count is detected, not chosen; the other five are the owner's, and so * are the cue points (#107) — optional, so a work saved before them reads * as a work with none. + * + * #153's glide is optional the other way about: absent means the orbit + * glides, and only an owner turning it off is written down. A work saved + * before the switch existed therefore reads as one that glides, which is + * the motion it should have had all along. */ export const r360ParamsSchema = z .object({ @@ -144,6 +149,7 @@ export const r360ParamsSchema = z startFrame: z.number().int().min(1).max(R360_MAX_FRAMES), flattening: z.number().min(0.15).max(1), cues: z.array(cueSchema).max(R360_CUES_MAX).optional(), + glide: z.boolean().optional(), }) .refine( (p) => p.framesPerWidth <= p.frameCount && p.startFrame <= p.frameCount, @@ -172,6 +178,8 @@ export function defaultR360Params(frameCount: number): R360Params { /** * Whether the parameters are the defaults for their count, untouched and * without cue points — nothing of the owner's in them to keep (#107). + * A glide turned off counts as something of theirs (#153); a glide left + * on is the default, and is not written down at all. */ export function isDefaultR360Params(params: R360Params): boolean { const defaults = defaultR360Params(params.frameCount); @@ -180,6 +188,7 @@ export function isDefaultR360Params(params: R360Params): boolean { params.framesPerWidth === defaults.framesPerWidth && params.startFrame === defaults.startFrame && params.flattening === defaults.flattening && + params.glide !== false && !params.cues?.length ); } diff --git a/src/lib/r360/orbit.test.ts b/src/lib/r360/orbit.test.ts index 79248cd..b4cabcf 100644 --- a/src/lib/r360/orbit.test.ts +++ b/src/lib/r360/orbit.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { + COAST_MAX_SPEED, + TRAVEL_MAX_MS, + coastAfterDrag, + coastOnRelease, + dragSpeed, frameAfterDrag, frameAfterKey, + framesAlong, + glides, loadingOrder, nearestLoaded, pageStep, @@ -12,7 +19,9 @@ import { } from "./orbit"; // #103/#104: the orbit's arithmetic, as decided on #68 — relative, -// discrete, wrapping, in the work's direction. +// discrete, wrapping, in the work's direction. #153 adds the shape of +// the motion: the curve a travel follows, and what a drag let go of +// carries with it. const params = ( frameCount: number, @@ -88,6 +97,235 @@ describe("frameAfterKey", () => { }); }); +describe("glides (#153)", () => { + it("is on unless the owner said otherwise: only false turns it off", () => { + expect(glides({})).toBe(true); + expect(glides({ glide: undefined })).toBe(true); + expect(glides({ glide: true })).toBe(true); + expect(glides({ glide: false })).toBe(false); + }); +}); + +describe("framesAlong", () => { + it("lists the frames a turn passes, the destination last and the frame it starts on not among them", () => { + expect(framesAlong(1, 3, 10)).toEqual([2, 3, 4]); + expect(framesAlong(1, -3, 10)).toEqual([10, 9, 8]); + expect(framesAlong(9, 4, 10)).toEqual([10, 1, 2, 3]); + }); + + it("has nowhere to go on a turn of nothing", () => { + expect(framesAlong(4, 0, 10)).toEqual([]); + }); + + // #153: a throw can be worth more than one turn of the orbit, and the + // path must carry every frame of it — the travel spends its time on + // the list it is given, not on the distance between two numbers. + it("keeps going past the last frame when the turn is longer than the orbit", () => { + expect(framesAlong(1, 12, 10)).toEqual([ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, + ]); + }); +}); + +describe("dragSpeed (#153)", () => { + const p = params(120, 10); + + it("is the frames the drag covered over the time it took, in the work's direction", () => { + // Half the picture's width in 100 ms, at 10 frames a width: 5 frames + // in 100 ms. + const samples = [ + { x: 0, t: 0 }, + { x: 50, t: 50 }, + { x: 100, t: 100 }, + ]; + expect(dragSpeed(samples, 200, p)).toBeCloseTo(0.05, 10); + expect(dragSpeed(samples, 200, params(120, 10, -1))).toBeCloseTo(-0.05, 10); + }); + + it("reads the oldest and the newest, whatever happened between them", () => { + // There and back again inside the window: the hand ends where it + // started and the orbit is going nowhere. + expect( + dragSpeed( + [ + { x: 0, t: 0 }, + { x: 90, t: 50 }, + { x: 0, t: 100 }, + ], + 200, + p, + ), + ).toBe(0); + }); + + it("is nothing when there is nothing to divide by", () => { + const two = [ + { x: 0, t: 0 }, + { x: 100, t: 100 }, + ]; + expect(dragSpeed([], 200, p)).toBe(0); + expect(dragSpeed([{ x: 0, t: 0 }], 200, p)).toBe(0); + expect(dragSpeed(two, 0, p)).toBe(0); + // One instant, read twice — and a clock that ran backwards, which is + // the same guard travelStop keeps for #161. + expect( + dragSpeed( + [ + { x: 0, t: 40 }, + { x: 100, t: 40 }, + ], + 200, + p, + ), + ).toBe(0); + expect( + dragSpeed( + [ + { x: 0, t: 100 }, + { x: 100, t: 0 }, + ], + 200, + p, + ), + ).toBe(0); + }); +}); + +// The numbers themselves are a feel, to be tried on a phone with Dawid; +// what is tested here is what must hold whatever they are tuned to. +describe("coastAfterDrag (#153)", () => { + it("goes the way the hand was going", () => { + // Half the cap: hard enough to carry whatever the feel is tuned to, + // and not so hard that both readings land on the cap and prove + // nothing. + const forward = coastAfterDrag(COAST_MAX_SPEED / 2); + const back = coastAfterDrag(-COAST_MAX_SPEED / 2); + expect(forward).not.toBeNull(); + expect(forward?.turn).toBeGreaterThan(0); + expect(back?.turn).toBe(-(forward?.turn ?? 0)); + expect(back?.ms).toBe(forward?.ms); + }); + + // Constant slowing: twice the speed takes twice as long to shed, and + // covers four times the ground doing it. + it("twice as fast runs twice as long and carries more than twice as far", () => { + // Both under the cap by construction, so the comparison is of the + // slowing and not of the clamp. + const slower = coastAfterDrag(COAST_MAX_SPEED / 4); + const faster = coastAfterDrag(COAST_MAX_SPEED / 2); + expect(faster?.ms).toBeCloseTo(2 * (slower?.ms ?? 0), 10); + expect(faster?.turn).toBeGreaterThan(2 * (slower?.turn ?? 0)); + }); + + it("takes a throw no further than a travel's longest, however hard it was", () => { + // Derived, not guessed: the inputs stay above the cap and the bound + // stays the travel's own, whatever the feel is later tuned to. + const hard = coastAfterDrag(COAST_MAX_SPEED * 4); + const harder = coastAfterDrag(COAST_MAX_SPEED * 400); + expect(hard?.ms).toBeLessThanOrEqual(TRAVEL_MAX_MS); + expect(harder).toEqual(hard); + }); + + // The floor is the frame itself: a throw that would not carry one is a + // hand that let go rather than threw, and the orbit stops where it is. + it("does not coast a hand that was barely moving, or not at all", () => { + expect(coastAfterDrag(0)).toBeNull(); + expect(coastAfterDrag(0.0001)).toBeNull(); + expect(coastAfterDrag(Number.NaN)).toBeNull(); + expect(coastAfterDrag(Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +// #153: the four ways a release does NOT throw the orbit, and the one +// way it does. This is the half of the coast that lives on a pointer +// event rather than in a number, and the half most likely to be undone +// by a tidy-up of the hook. +describe("coastOnRelease (#153)", () => { + const p = params(120, 10); + // A hand crossing a fifth of a 200 px picture in the 40 ms before it + // let go: 2 frames in 40 ms at 10 frames a width, well over the floor. + const thrown = { + samples: [ + { x: 0, t: 960 }, + { x: 20, t: 980 }, + { x: 40, t: 1000 }, + ], + lift: { x: 40, t: 1000 }, + width: 200, + lifted: true, + glide: true, + reducedMotion: false, + }; + /** The same throw, the hand resting `rest` ms before it lets go. */ + const rested = (rest: number) => ({ + ...thrown, + lift: { x: 40, t: 1000 + rest }, + }); + + /** The same throw, the hand going the other way. */ + const mirrored = { + ...thrown, + samples: thrown.samples.map((s) => ({ ...s, x: -s.x })), + lift: { ...thrown.lift, x: -thrown.lift.x }, + }; + + it("throws the orbit the way a hand still moving was going", () => { + const coast = coastOnRelease(thrown, p); + expect(coast).not.toBeNull(); + expect(coast?.turn).toBeGreaterThan(0); + expect(coastOnRelease(mirrored, p)?.turn).toBe(-(coast?.turn ?? 0)); + }); + + // The one with no flag of its own: a hand at rest states itself, which + // is why there is no threshold here to drift out of step with anything. + it("does not throw a hand that came to rest before it let go", () => { + expect(coastOnRelease(rested(500), p)).toBeNull(); + }); + + // What that rest must NOT be is a step. Measured between moves, the + // time a hand spends still before lifting never reaches the divisor: + // the throw would keep its full speed until the window emptied and + // then vanish. A finger lifts tens of milliseconds after it stops, so + // that edge is the ordinary gesture, and it would have spun the orbit + // most of the way round on a drag the visitor had already finished. + it("drains the throw as the hand rests, rather than all at once", () => { + const turnAfter = (rest: number) => coastOnRelease(rested(rest), p)?.turn; + const straight = turnAfter(0); + expect(straight).toBeGreaterThan(0); + expect(turnAfter(40)).toBeLessThan(straight!); + expect(turnAfter(70)).toBeLessThan(turnAfter(40)!); + expect(coastOnRelease(rested(150), p)).toBeNull(); + }); + + it("does not throw a pointer that was cancelled or taken away", () => { + expect(coastOnRelease({ ...thrown, lifted: false }, p)).toBeNull(); + }); + + it("does not throw an orbit whose owner turned the glide off", () => { + expect(coastOnRelease({ ...thrown, glide: false }, p)).toBeNull(); + }); + + // Reduced motion wins over the owner: a jump is not a substitute for a + // coast, so there is nothing to fall back to and the orbit stops dead. + it("does not throw for a visitor whose system asks for less motion", () => { + expect(coastOnRelease({ ...thrown, reducedMotion: true }, p)).toBeNull(); + }); + + it("has nothing to measure from the lift alone, or a picture with no width", () => { + expect(coastOnRelease({ ...thrown, samples: [] }, p)).toBeNull(); + expect(coastOnRelease({ ...thrown, width: 0 }, p)).toBeNull(); + }); + + // The press is not a reading (the hook does not record it), so a tap + // that wobbled past the slop once and stopped has one move and a lift + // at the same place: no distance, no throw. + it("does not throw a tap that wobbled and stopped", () => { + expect( + coastOnRelease({ ...thrown, samples: [{ x: 40, t: 990 }] }, p), + ).toBeNull(); + }); +}); + describe("travelStop", () => { const path = [4, 1]; @@ -115,6 +353,53 @@ describe("travelStop", () => { expect(travelStop(path, 0, 0)).toEqual({ frame: 1, arrived: true }); }); + // #153: the same path and the same time, the frames spread differently + // along it. A ten-frame path over a second, read against the constant + // pace every travel had before. + it("eased lingers at the start and settles onto its frame early", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + // A tenth of the way through, an eased travel has not left its first + // frame; a steady one is already on the second. + expect(travelStop(ten, 100, 1000, "eased").frame).toBe(1); + expect(travelStop(ten, 100, 1000).frame).toBe(2); + // Halfway is halfway either way — the curve is symmetric. + expect(travelStop(ten, 500, 1000, "eased").frame).toBe(6); + expect(travelStop(ten, 500, 1000).frame).toBe(6); + // And it is on its last frame with time left to settle there. + expect(travelStop(ten, 850, 1000, "eased").frame).toBe(10); + expect(travelStop(ten, 850, 1000).frame).toBe(9); + }); + + it("slowing spends its speed early: three quarters of the path in half the time", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + expect(travelStop(ten, 500, 1000, "slowing").frame).toBe(8); + expect(travelStop(ten, 500, 1000).frame).toBe(6); + expect(travelStop(ten, 200, 1000, "slowing").frame).toBe(4); + expect(travelStop(ten, 200, 1000).frame).toBe(3); + }); + + // A curve is a way of spending the time, not of changing where the + // travel ends or when it is over: an animation frame that arrives late + // reads a progress past 1, and smoothstep of 1.2 turns back DOWN the + // path — the orbit would walk backwards out of its destination. + it("ends on the destination, on time, whatever curve it took", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + for (const curve of ["steady", "eased", "slowing"] as const) { + expect(travelStop(ten, 1000, 1000, curve), curve).toEqual({ + frame: 10, + arrived: true, + }); + expect(travelStop(ten, 9_000, 1000, curve), curve).toEqual({ + frame: 10, + arrived: true, + }); + expect(travelStop(ten, -9_000, 1000, curve), curve).toEqual({ + frame: 1, + arrived: false, + }); + } + }); + it("has nowhere to be with no path, and says so", () => { const { frame, arrived } = travelStop([], 10, 250); expect(Number.isNaN(frame)).toBe(true); diff --git a/src/lib/r360/orbit.ts b/src/lib/r360/orbit.ts index ee6c1d9..ada4098 100644 --- a/src/lib/r360/orbit.ts +++ b/src/lib/r360/orbit.ts @@ -2,6 +2,10 @@ // frame at a time, numbered 1..N in the viewer whatever the file names // said, wrapping past the last. Pure, so the hook and the viewer stay thin // hands on the pointer and the keyboard, and the mapping is tested here. +// +// #153 puts the shape of the motion here too: the curve a travel +// follows, how fast a drag was going when the hand let go, and how far +// that carries the orbit afterwards. export interface OrbitParams { frameCount: number; @@ -10,6 +14,37 @@ export interface OrbitParams { /** Frames per picture width: how far a drag across the whole picture goes. */ framesPerWidth: number; startFrame: number; + /** + * #153: whether the orbit glides — a travel that eases in and out of + * its frame, a drag that coasts on after the hand. Absent is ON: the + * field arrived after works were saved, and what it names is the + * motion every orbit should have had. Only `false` is an owner who + * turned it off. + */ + glide?: boolean; +} + +/** #153: whether this orbit glides. Absent is on; only `false` is off. */ +export function glides(params: Pick): boolean { + return params.glide !== false; +} + +/** + * A travel's pace (#106), and the bounds that keep a long one from + * dragging on. Here rather than in the hook because #153's coast may not + * outlast a travel, and a cap that repeats the number in another file is + * a cap that stops being true the day somebody tunes the original. + */ +const TRAVEL_MS_PER_FRAME = 28; +const TRAVEL_MIN_MS = 250; +export const TRAVEL_MAX_MS = 1200; + +/** How long a travel over `pathLength` frames takes, within the bounds. */ +export function travelDuration(pathLength: number): number { + return Math.min( + TRAVEL_MAX_MS, + Math.max(TRAVEL_MIN_MS, pathLength * TRAVEL_MS_PER_FRAME), + ); } /** The non-negative modulo: JavaScript's `%` keeps the dividend's sign. */ @@ -39,6 +74,132 @@ export function frameAfterDrag( return wrapFrame(anchorFrame + params.direction * steps, params.frameCount); } +/** #153: where the pointer was, and when — one reading of a drag. */ +export interface DragSample { + x: number; + /** Milliseconds; every sample of one drag read from the same clock. */ + t: number; +} + +/** + * #153: how fast a drag is turning the orbit when it is let go, in + * frames per millisecond, signed as the frame numbers run. Measured + * between the oldest and the newest sample given — the caller hands over + * only the recent ones, so a hand that came to rest before it let go has + * nothing here to measure and the orbit stops where it is. + * + * Zero wherever there is nothing to divide by: one sample, a picture + * without width, two readings of the same instant — or a clock that ran + * backwards, which `travelStop` guards against for its own reasons. + */ +export function dragSpeed( + samples: readonly DragSample[], + width: number, + params: Pick, +): number { + if (samples.length < 2 || width <= 0) return 0; + const first = samples[0]; + const last = samples[samples.length - 1]; + const span = last.t - first.t; + // Not `span <= 0`: a NaN from either reading must land here too. + if (!(span > 0)) return 0; + const frames = ((last.x - first.x) / width) * params.framesPerWidth; + return (params.direction * frames) / span; +} + +/** + * #153: how fast a coast sheds speed, in frames per millisecond squared. + * The one number that decides both how long a throw runs and how far it + * carries, and so the one to turn when the feel is wrong — to be tuned + * on a phone with Dawid. + */ +const COAST_SLOWING = 1 / 12_000; +/** + * The fastest a coast may start: a harder throw than this is taken as + * this one. Derived from a travel's longest rather than stated again, so + * that no motion of the orbit outlasts another however either is tuned. + */ +export const COAST_MAX_SPEED = COAST_SLOWING * TRAVEL_MAX_MS; + +/** + * #153: where a drag let go at `speed` coasts to, and how long it takes + * to get there — the frames to turn, signed, and the milliseconds to + * spend slowing to a stop. The slowing is constant, so the distance is + * half the speed times the time, and `travelStop`'s "slowing" curve is + * that same motion drawn out frame by frame. + * + * Null when the throw would not carry a whole frame: a hand that let go + * rather than threw leaves the orbit where it is, as it always did. That + * is the floor, and there is no second threshold to keep in step with it. + */ +export function coastAfterDrag( + speed: number, +): { turn: number; ms: number } | null { + if (!Number.isFinite(speed)) return null; + const capped = Math.max(-COAST_MAX_SPEED, Math.min(COAST_MAX_SPEED, speed)); + const ms = Math.abs(capped) / COAST_SLOWING; + const turn = Math.round((capped * ms) / 2); + return turn === 0 ? null : { turn, ms }; +} + +/** + * #153: how far back a release looks for the speed to coast at. Long + * enough to hold several moves at any refresh rate, short enough that a + * hand which came to rest before it let go leaves nothing inside it — + * which is what makes a deliberate stop stop, with no threshold to tune. + */ +const COAST_SAMPLE_MS = 100; +/** + * Readings a drag keeps. More than COAST_SAMPLE_MS can hold at any + * refresh rate, so the window decides what counts and not the slicing. + */ +export const COAST_SAMPLES_KEPT = 12; + +/** + * #153: the whole of what a release decides — whether the orbit coasts + * on, and if so how far and for how long. Here rather than in the hook + * so that every way of NOT coasting can be put to the test: a pointer + * that was cancelled rather than lifted, an owner who turned the glide + * off, a visitor whose system asks for less motion, and a hand that was + * slowing or standing still when it let go. + * + * That last one is why the lift is a reading like any other rather than + * merely the moment of asking. Measured between MOVES, the time a hand + * spends resting before it lets go never reaches the divisor: the speed + * would stay exactly what it was while the hand was still travelling, + * right up to the moment the window empties, and then fall to nothing. + * A finger lifts tens of milliseconds after it stops — the ordinary + * gesture — so that step would have thrown the orbit most of the way + * round on a drag the visitor had already finished. With the lift in the + * readings, resting lengthens the span without lengthening the distance, + * and the throw drains smoothly to nothing. + */ +export function coastOnRelease( + release: { + samples: readonly DragSample[]; + /** + * Where and when the pointer lifted: the drag's last reading, and + * the clock the window is measured back from. + */ + lift: DragSample; + /** The picture's width at the grab, which the drag was measured in. */ + width: number; + /** The pointer was LIFTED — not cancelled, not taken away. */ + lifted: boolean; + glide: boolean; + reducedMotion: boolean; + }, + params: Pick, +): { turn: number; ms: number } | null { + if (!release.lifted || !release.glide || release.reducedMotion) { + return null; + } + const recent = [...release.samples, release.lift].filter( + (reading) => release.lift.t - reading.t <= COAST_SAMPLE_MS, + ); + return coastAfterDrag(dragSpeed(recent, release.width, params)); +} + /** A twelfth of the orbit, at least one frame — the Page keys' step. */ export function pageStep(frameCount: number): number { return Math.max(1, Math.round(frameCount / 12)); @@ -79,6 +240,33 @@ export function frameAfterKey( } } +/** + * #153: the shape a travel's progress takes. `steady` gives every frame + * the same slice of the time — what a travel always did, and what an + * orbit whose owner turned the glide off still does. `eased` starts from + * rest, runs fastest halfway and settles onto its frame: a click on the + * ring. `slowing` starts at the hand's speed and comes to a stop — and + * that one is not chosen for the look of it, it is where constant + * slowing puts a thing, the very motion `coastAfterDrag` measures out. + */ +export type TravelCurve = "steady" | "eased" | "slowing"; + +// Every member named, and no `default`: a curve added to the union and +// forgotten here is then a compile error, not a travel that quietly runs +// at a flat pace. +function alongCurve(progress: number, curve: TravelCurve): number { + switch (curve) { + case "steady": + return progress; + case "eased": + // Smoothstep: still at both ends, fastest in the middle. + return progress * progress * (3 - 2 * progress); + case "slowing": + // 2t − t²: full speed at the start, none at the end. + return progress * (2 - progress); + } +} + /** * Where a travel stands (#106): the frame to show after `elapsed` of its * `duration`, and whether that frame is the destination. Every frame on @@ -95,16 +283,27 @@ export function frameAfterKey( * * An empty path has nowhere to be: no frame, and arrived. The hook never * asks, and `place` ignores a frame that is not a number. + * + * #153: `curve` is how the time is spread over the path — equally, or + * gathered towards one end. It decides which frame is shown when, never + * where the travel ends or when it is over. */ export function travelStop( path: readonly number[], elapsed: number, duration: number, + curve: TravelCurve = "steady", ): { frame: number; arrived: boolean } { if (path.length === 0) return { frame: Number.NaN, arrived: true }; const since = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; const progress = duration > 0 ? since / duration : 1; - const at = Math.min(path.length - 1, Math.floor(progress * path.length)); + // A curve is only itself over the travel's own time: an animation + // frame that came late reads a progress past 1, and smoothstep of 1.2 + // turns back DOWN the path. Clamped for the curve, raw for the arrival. + const at = Math.min( + path.length - 1, + Math.floor(alongCurve(Math.min(1, progress), curve) * path.length), + ); return { frame: path[at], arrived: progress >= 1 }; } @@ -118,6 +317,23 @@ export function shortestTurn( return forward <= frameCount - forward ? forward : forward - frameCount; } +/** + * The frames a turn of `turn` from `from` passes through — the + * destination last, `from` itself not among them. A turn longer than the + * frame count wraps and keeps going, because a coast can carry the orbit + * more than once round (#153). + */ +export function framesAlong( + from: number, + turn: number, + frameCount: number, +): number[] { + const step = Math.sign(turn); + return Array.from({ length: Math.abs(turn) }, (_, i) => + wrapFrame(from + step * (i + 1), frameCount), + ); +} + /** * The order the visitor's frames load in (#104): the start frame, then * every 8th, every 4th, every 2nd, the rest — so the orbit is usable diff --git a/src/lib/r360/ring.ts b/src/lib/r360/ring.ts index f7fb7ac..cc5500a 100644 --- a/src/lib/r360/ring.ts +++ b/src/lib/r360/ring.ts @@ -1,4 +1,9 @@ -import { shortestTurn, wrapFrame, type OrbitParams } from "./orbit"; +import { + framesAlong, + shortestTurn, + wrapFrame, + type OrbitParams, +} from "./orbit"; // #106 (A13, #68 decision 2): the ring dial's geometry. A circle flattened // to the elevation the render camera had (f from 0.15 to 1, 1 = a circle), @@ -89,10 +94,7 @@ export function travelPath( // A tie — half the orbit either way — comes back from shortestTurn as +; // the work's direction decides it here. const signed = Math.abs(turn) * 2 === frameCount ? direction * turn : turn; - const step = Math.sign(signed); - return Array.from({ length: Math.abs(signed) }, (_, i) => - wrapFrame(from + step * (i + 1), frameCount), - ); + return framesAlong(from, signed, frameCount); } /** A stretch of consecutive loaded frames, possibly across the wrap. */ diff --git a/tasks/plan.md b/tasks/plan.md index 964b066..5a148a1 100644 --- a/tasks/plan.md +++ b/tasks/plan.md @@ -294,11 +294,17 @@ recorded so it is not rediscovered later. settled: ~~[#151](https://github.com/Devski/platform-lite/issues/151) an orbit from a RAR archive~~ — **dropped 12.09.2026** at his word, we do not support RAR for now; a zip is what the export tools produce, and the readers RAR would need are not worth carrying - against that. The other two stand: + against that. Of the other two, one still stands: [#152](https://github.com/Devski/platform-lite/issues/152) orbits on one page loading one after another, because the page's frame queue is first come, first served and an orbit - in view queues its whole set; [#153](https://github.com/Devski/platform-lite/issues/153) - motion that eases on a ring click and coasts after a drag, with the owner's switch. + in view queues its whole set. The last is done: + ~~[#153](https://github.com/Devski/platform-lite/issues/153) motion that eases on a ring + click and coasts after a drag~~ — **done 12.09.2026**: a travel eases in and out of its + frame, a drag thrown with the hand coasts on and slows to a stop, and the owner has a + sixth parameter to turn both off per work. The coast is not a new mechanism — it is a + travel along the frames the throw would carry, so a grab, a key and the #161 landing + hold it exactly as they hold a ring click. Reduced motion still wins over all of it. + The curve and the length of a coast are to be tried on a phone with Dawid. ## Requirements coverage