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 (
-
+ ({
+ 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 (
+
+ );
+}
+
/** 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