diff --git a/e2e/db/r360.spec.ts b/e2e/db/r360.spec.ts index c95fbf1..9b06368 100644 --- a/e2e/db/r360.spec.ts +++ b/e2e/db/r360.spec.ts @@ -284,6 +284,60 @@ test("cue points (#107): the buttons under the picture turn the orbit to their f await expectNoAxeViolations(visitor, test.info(), "public-r360-cues"); }); +// #175: the cue buttons answer the press, not the arrival. Its own context +// for the same reason as the #161 test below: the stub outlives the click. +test("a cue button lights the moment it is pressed, and the cues on the way stay unlit (#175)", async () => { + const unanimated = await visitor + .context() + .browser()! + .newContext({ locale: "pl-PL", reducedMotion: "no-preference" }); + const page = await unanimated.newPage(); + await page.goto(`/${identity.handle}`); + const card = page.getByRole("article").filter({ hasText: "Dom na skarpie" }); + const viewer = card.getByTestId("orbit-viewer"); + await expect(viewer).toHaveAttribute("data-frame", "3"); + + // No animation frames, so the orbit cannot arrive on its own: whatever + // the button does now, it does on the strength of the press alone. The + // landing (#161) still ends the travel about 450 ms later, which is why + // the assertions below are the first thing after the click. + await page.evaluate(() => { + let handle = 0; + window.requestAnimationFrame = () => ++handle; + }); + + const cues = card.getByRole("list", { + name: "Punkty widoku 360°: Dom na skarpie", + }); + const entrance = cues.getByRole("button", { name: "Wejście główne" }); + const terrace = cues.getByRole("button", { name: "Taras" }); + + await entrance.click(); + // Lit at once, while the orbit is demonstrably still elsewhere: before + // #175 the row read the CURRENT frame, so this said nothing until the + // travel finished. + await expect(entrance).toHaveAttribute("aria-current", "true", { + timeout: 300, + }); + await expect(viewer).toHaveAttribute("data-frame", "3"); + // And the cue the path crosses does not claim it was asked for. (It used + // to light for the 28 ms the orbit stood on it — a blink, not a signal.) + await expect(terrace).not.toHaveAttribute("aria-current"); + + // The landing still puts the orbit where it was sent. + await expect(viewer).toHaveAttribute("data-frame", "1", { timeout: 2_000 }); + await expect(entrance).toHaveAttribute("aria-current", "true"); + + // A hand taking hold drops the claim: the orbit is no longer going there. + await terrace.click(); + await viewer.focus(); + await page.keyboard.press("ArrowRight"); + await expect(terrace).not.toHaveAttribute("aria-current"); + await expect(entrance).not.toHaveAttribute("aria-current"); + + await unanimated.close(); +}); + // #161: a travel steps frame by frame on animation frames, and a page that // is not being drawn is given none — a tab put aside mid-turn, a window // behind another, a clock that steps back under a virtual machine. Its own diff --git a/messages/en.json b/messages/en.json index cb6ccec..a11edfa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,7 +574,10 @@ "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.", + "paramEaseIn": "Ease in", + "paramEaseOut": "Ease out", + "easePercent": "{percent}%", + "glideHint": "Ease in and ease out are the two sliders below. 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 f2d9452..a1feec5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -574,7 +574,10 @@ "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.", + "paramEaseIn": "Rozbieg", + "paramEaseOut": "Dobieg", + "easePercent": "{percent}%", + "glideHint": "Rozbieg i dobieg ustawiasz suwakami poniżej. 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 22d4adf..0a18b73 100644 --- a/src/app/[locale]/(public)/[handle]/r360-params.tsx +++ b/src/app/[locale]/(public)/[handle]/r360-params.tsx @@ -111,6 +111,36 @@ export function R360ParamControls({ testId: `work-r360-glide-${on ? "on" : "off"}`, }))} /> + {/* #175: how much it gathers pace and how much it settles, each its + own half of the travel. Only under the switch: with the motion off + there is no curve to shape. Absent means the full ease, so a work + saved before these existed reads as 100/100 and feels unchanged. */} + {glides(params) && ( + <> + onChange({ easeIn: easeValue(percent) })} + disabled={disabled} + testId="work-r360-ease-in" + /> + onChange({ easeOut: easeValue(percent) })} + disabled={disabled} + testId="work-r360-ease-out" + /> + + )} = 100 ? undefined : percent / 100; +} + function RangeParam({ name, shown, diff --git a/src/app/globals.css b/src/app/globals.css index e362ff0..8c65658 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -26,10 +26,9 @@ /* Overrides Tailwind's default font-sans/font-mono utilities (and the Preflight body font) so nothing renders in the system stack. */ --font-sans: "Figtree", "Helvetica Neue", Helvetica, Arial, sans-serif; - --font-mono: - "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - --font-plaque: "Fira Sans Condensed", "Frutiger Condensed", "Segoe UI", - sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --font-plaque: + "Fira Sans Condensed", "Frutiger Condensed", "Segoe UI", sans-serif; /* colors.css — monochrome, cool-slate neutrals. The only saturated colors belong to the brand plaque and to state; never used for interface @@ -154,6 +153,11 @@ --ring-focus: 0 0 0 2px var(--surface-card), 0 0 0 4px var(--focus-ring); --ring-focus-inverse: 0 0 0 2px rgba(12, 17, 22, 0.6), 0 0 0 4px var(--focus-ring-inverse); + /* #175: the ring a cue button grows for a moment as the orbit passes it. + The focus colour at half strength — it borrows the meaning ("this one, + just now") without being mistaken for focus itself, and it is a shadow, + so it never touches the text or fill the contrast was measured on. */ + --ring-cue-pulse: color-mix(in oklab, var(--focus-ring) 50%, transparent); --blur-panel: saturate(120%) blur(14px); /* radius.css — semantic aliases onto the scale above. */ @@ -255,6 +259,37 @@ --type-eyebrow: var(--fw-semibold) var(--fs-micro) / 1.2 var(--font-sans); } +@layer base { + /* #175: a button says it is pressable. Tailwind 4's preflight no longer + sets this (verified in node_modules/tailwindcss/preflight.css, 4.3.3), + so every button in this product showed the plain arrow — Dawid noticed + it on the cue row, and it was true of all of them. Here rather than in + the Button component, because half the buttons in the app are bare + elements, and a disabled one keeps the cursor that says so. */ + button:not(:disabled):not([aria-disabled="true"]), + [role="button"]:not([aria-disabled="true"]) { + cursor: pointer; + } +} + +/* #175: an orbit passing a cue point on its way elsewhere. A ring that + grows and fades — never the tones, which cannot fade between each other + without passing through colours that cannot be read (#107). Short enough + to read as "went by", long enough to be seen at all: the orbit stands on + the crossed frame for about 28 ms. */ +@keyframes cue-pulse { + from { + box-shadow: 0 0 0 0 var(--ring-cue-pulse); + } + to { + box-shadow: 0 0 0 0.5rem transparent; + } +} + +@theme { + --animate-cue-pulse: cue-pulse 450ms ease-out; +} + @layer components { /* One class per --type-* shorthand, named to match 1:1 — avoids both Tailwind's text-{size} utility namespace and the --text-* color diff --git a/src/components/ui/orbit-cues.tsx b/src/components/ui/orbit-cues.tsx index 383d159..8711251 100644 --- a/src/components/ui/orbit-cues.tsx +++ b/src/components/ui/orbit-cues.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import type { R360Cue } from "@/lib/r360/frame-set-shared"; import type { OrbitParams } from "@/lib/r360/orbit"; import { travelPath } from "@/lib/r360/ring"; @@ -68,6 +69,29 @@ export function OrbitCueButtons({ tone?: "light" | "dark"; className?: string; }) { + // #175: the cues this motion has crossed, held until their pulse has + // finished playing. Derived state would not do: the orbit stands on a + // crossed frame for one animation frame — 28 ms, less through the fast + // middle of an eased travel — and an animation whose attribute is taken + // away is an animation cut off, which is the blink again in another + // property. Each entry clears itself on animationend. + const [crossed, setCrossed] = useState([]); + const passedThrough = orbit.moving ? orbit.frame : null; + const [wasOn, setWasOn] = useState(null); + if (passedThrough !== wasOn) { + setWasOn(passedThrough); + // Only a frame the motion MOVED onto, and only one it is not headed + // for: the frame a travel starts from was never crossed, and the one + // it ends on is arrived at. + if ( + passedThrough !== null && + wasOn !== null && + passedThrough !== orbit.aimedAt && + !crossed.includes(passedThrough) + ) { + setCrossed([...crossed, passedThrough]); + } + } if (cues.length === 0) return null; const look = TONES[tone]; return ( @@ -77,7 +101,21 @@ export function OrbitCueButtons({ data-testid="orbit-cues" > {cues.map((cue) => { - const here = cue.frame === orbit.frame; + // #175: where the orbit was ASKED to be — the frame it is travelling + // to while it travels, the frame it stands on when it is still. + // Reading the current frame instead meant a press did nothing until + // the orbit arrived, and that every cue a motion crossed lit for the + // single frame it stood there: a blink, not a signal. A coast is + // moving with nowhere asked for, so nothing is marked until it + // stops. + const here = orbit.moving + ? cue.frame === orbit.aimedAt + : cue.frame === orbit.frame; + // Crossed on the way elsewhere: a pulse says the orbit went by, and + // says it in a ring rather than in the tones, which cannot fade + // between each other without passing through colours that cannot be + // read (#107, caught by axe in the lightbox). + const passing = crossed.includes(cue.frame); const state = here ? look.here : cue.frame === preview @@ -112,7 +150,13 @@ export function OrbitCueButtons({ // frame, a readout like the counter, and a fade between the // two tones passes through ones that cannot be read (axe // caught one mid-way in the lightbox). - className={`inline-flex min-h-8 items-center gap-(--sp-3) rounded-full border px-(--sp-4) type-label focus-visible:outline-none ${look.focus} ${state}`} + data-passing={passing ? "" : undefined} + onAnimationEnd={() => + setCrossed((frames) => + frames.filter((frame) => frame !== cue.frame), + ) + } + className={`inline-flex min-h-8 items-center gap-(--sp-3) rounded-full border px-(--sp-4) type-label focus-visible:outline-none data-passing:animate-cue-pulse motion-reduce:data-passing:animate-none ${look.focus} ${state}`} > {/* The ring's marker, in small: this button is that diamond. */} void; + /** + * #175: the frame an aimed travel is on its way to, or null when the + * orbit is standing still, being dragged, or coasting. What the cue + * buttons light on — the frame that was ASKED for, not the one the orbit + * happens to be crossing. + */ + aimedAt: number | null; + /** + * #175: whether the orbit is moving of its own accord — an aimed travel + * or a coast. What tells a readout "do not call any frame the current + * one yet": the frames a motion crosses are passed through, not arrived + * at, and a row that marks them marks a blink. + */ + moving: boolean; cancelTravel: () => void; dragging: boolean; /** Spread onto the element that is the picture. */ @@ -98,6 +113,15 @@ export function useOrbit( // defaults inline), and a callback keyed on the object itself would be // rebuilt with every one of them. const glide = glides(params); + // #175: one curve for every travel, whichever hand started it — the ring, + // a marker on it, or a cue button all come through travelAlong. Memoised + // for the reason stated just above: it is an object, and the parameters + // it is built from arrive fresh on some renders. + const curve = useMemo( + () => + travelCurve({ glide, easeIn: params.easeIn, easeOut: params.easeOut }), + [glide, params.easeIn, params.easeOut], + ); const [frame, setFrameState] = useState(() => wrapFrame(options.initialFrame ?? params.startFrame, frameCount), ); @@ -119,7 +143,14 @@ export function useOrbit( // landing that does not depend on it (#161). const travel = useRef(undefined); const landing = useRef | undefined>(undefined); + // #175: the frame an aimed travel is on its way to, or null — and + // whether the orbit is moving on its own at all, which a coast does + // without anyone having asked for the frame it stops on. + const [aimedAt, setAimedAt] = useState(null); + const [coasting, setCoasting] = useState(false); const cancelTravel = useCallback(() => { + setAimedAt(null); + setCoasting(false); if (travel.current !== undefined) { window.cancelAnimationFrame(travel.current); } @@ -199,13 +230,27 @@ export function useOrbit( (path: readonly number[]) => { cancelTravel(); if (path.length === 0) return; + const destination = path[path.length - 1]; if (reducedMotion()) { - place(path[path.length - 1]); + place(destination); return; } - run(path, travelDuration(path.length), glide ? "eased" : "steady"); + // #175: where this orbit is headed, for as long as it is headed + // there. A cue button lights on the press rather than on the arrival, + // and the cues the travel passes on the way stay unlit — the row was + // reading the CURRENT frame, so every cue crossed by a travel flashed + // for the 28 ms it stood on it. + // + // Only an aimed travel sets this. A coast has an end too, but nobody + // asked for that frame, so nothing should claim it was asked for. + // After `run`, not before: it begins by cancelling whatever travel was + // in flight, and cancelling is what drops the aim — set first, the + // press would light the button for as long as it takes the next line + // to run, which is no time at all. + run(path, travelDuration(path.length), curve); + setAimedAt(destination); }, - [cancelTravel, glide, place, run], + [cancelTravel, curve, place, run], ); // A frame count that changed under the hook (a new archive in the same @@ -289,6 +334,10 @@ export function useOrbit( coast.ms, "slowing", ); + // After `run`, like the aim above and for the same reason (#175): a + // coast passes cues too, and while it does, none of them is where the + // orbit was asked to be — nobody asked. + setCoasting(true); }, [direction, frameCount, framesPerWidth, glide, run], ); @@ -305,6 +354,8 @@ export function useOrbit( return { frame, + aimedAt, + moving: aimedAt !== null || coasting, setFrame, travelAlong, cancelTravel, diff --git a/src/lib/r360/frame-set-shared.ts b/src/lib/r360/frame-set-shared.ts index 6c71e9f..ea0361a 100644 --- a/src/lib/r360/frame-set-shared.ts +++ b/src/lib/r360/frame-set-shared.ts @@ -150,6 +150,11 @@ export const r360ParamsSchema = z flattening: z.number().min(0.15).max(1), cues: z.array(cueSchema).max(R360_CUES_MAX).optional(), glide: z.boolean().optional(), + // #175: how much a travel gathers pace and how much it settles, each + // absent meaning the full ease #153 shipped — so nothing saved before + // them changes its feel, and only an owner who tuned it is written down. + easeIn: z.number().min(0).max(1).optional(), + easeOut: z.number().min(0).max(1).optional(), }) .refine( (p) => p.framesPerWidth <= p.frameCount && p.startFrame <= p.frameCount, @@ -189,6 +194,10 @@ export function isDefaultR360Params(params: R360Params): boolean { params.startFrame === defaults.startFrame && params.flattening === defaults.flattening && params.glide !== false && + // #175: an amount the owner tuned is theirs to keep, the same way a + // glide turned off is. Absent is the default and is not written down. + params.easeIn === undefined && + params.easeOut === undefined && !params.cues?.length ); } diff --git a/src/lib/r360/orbit.test.ts b/src/lib/r360/orbit.test.ts index b4cabcf..f1aa255 100644 --- a/src/lib/r360/orbit.test.ts +++ b/src/lib/r360/orbit.test.ts @@ -13,6 +13,7 @@ import { nearestLoaded, pageStep, shortestTurn, + travelCurve, travelStop, wrapFrame, type OrbitParams, @@ -355,21 +356,81 @@ describe("travelStop", () => { // #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. + // pace every travel had before. #175 made the eased shape two amounts; + // full both ways is the curve #153 shipped, so this test is unchanged + // except for how the curve is named. + const EASED = { easeIn: 1, easeOut: 1 }; 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, 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, 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, EASED).frame).toBe(10); expect(travelStop(ten, 850, 1000).frame).toBe(9); }); + // #175: each amount bends its own half of the travel, and nothing else. + it("eases each end by its own amount, and none at all at zero", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const none = { easeIn: 0, easeOut: 0 }; + // Nothing either way is the constant pace, frame for frame. + for (const at of [100, 200, 500, 850]) { + expect(travelStop(ten, at, 1000, none).frame, `at ${at}`).toBe( + travelStop(ten, at, 1000).frame, + ); + } + // Gathering pace but not settling: slow away from the first frame, + // then straight on to the last — which it reaches no earlier than a + // travel at a constant pace does. + const inOnly = { easeIn: 1, easeOut: 0 }; + expect(travelStop(ten, 100, 1000, inOnly).frame).toBe(1); + expect(travelStop(ten, 850, 1000, inOnly).frame).toBe(9); + // Settling but not gathering: off the mark at once, then easing in. + const outOnly = { easeIn: 0, easeOut: 1 }; + expect(travelStop(ten, 100, 1000, outOnly).frame).toBe(2); + expect(travelStop(ten, 850, 1000, outOnly).frame).toBe(10); + // Halfway is halfway whatever the amounts: the two halves are bent + // around the point they share, so no mixture puts a step in the middle. + for (const curve of [none, inOnly, outOnly, EASED]) { + expect(travelStop(ten, 500, 1000, curve).frame).toBe(6); + } + }); + + // The type is exported, so an amount can reach alongCurve without having + // been through travelCurve. Unclamped, a blend of 5 turns back DOWN the + // path and travelStop hands out `path[-3]` — undefined, from a function + // whose type says number. + it("clamps an amount it is handed directly, and stays on the path", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + for (const at of [0, 100, 250, 500, 750, 1000]) { + const wild = travelStop(ten, at, 1000, { easeIn: 5, easeOut: -3 }); + expect(ten, `at ${at}`).toContain(wild.frame); + } + // 5 clamps to 1 and −3 to 0, so it is the same travel as 1 and 0. + expect(travelStop(ten, 100, 1000, { easeIn: 5, easeOut: -3 }).frame).toBe( + travelStop(ten, 100, 1000, { easeIn: 1, easeOut: 0 }).frame, + ); + }); + + it("reads an amount outside 0..1, or none at all, as the full ease", () => { + const ten = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const full = travelStop(ten, 100, 1000, EASED).frame; + expect(travelStop(ten, 100, 1000, travelCurve({})).frame).toBe(full); + expect( + travelStop(ten, 100, 1000, travelCurve({ easeIn: Number.NaN })).frame, + ).toBe(full); + expect(travelStop(ten, 100, 1000, travelCurve({ easeIn: 9 })).frame).toBe( + full, + ); + // And the switch still wins over both amounts. + expect(travelCurve({ glide: false, easeIn: 1, easeOut: 1 })).toBe("steady"); + }); + 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); @@ -384,16 +445,21 @@ describe("travelStop", () => { // 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({ + for (const curve of [ + "steady", + "slowing", + EASED, + { easeIn: 0, easeOut: 1 }, + ] as const) { + expect(travelStop(ten, 1000, 1000, curve), String(curve)).toEqual({ frame: 10, arrived: true, }); - expect(travelStop(ten, 9_000, 1000, curve), curve).toEqual({ + expect(travelStop(ten, 9_000, 1000, curve), String(curve)).toEqual({ frame: 10, arrived: true, }); - expect(travelStop(ten, -9_000, 1000, curve), curve).toEqual({ + expect(travelStop(ten, -9_000, 1000, curve), String(curve)).toEqual({ frame: 1, arrived: false, }); diff --git a/src/lib/r360/orbit.ts b/src/lib/r360/orbit.ts index ada4098..1b8fc73 100644 --- a/src/lib/r360/orbit.ts +++ b/src/lib/r360/orbit.ts @@ -22,6 +22,15 @@ export interface OrbitParams { * turned it off. */ glide?: boolean; + /** + * #175: how much a travel gathers pace at the start and settles at the + * end, each 0..1 and each absent meaning 1 — the full ease #153 shipped, + * so a work saved before these existed feels exactly as it did. 0 on a + * side is a straight line there: 1 in and 0 out gathers pace and then + * runs flat into its frame. + */ + easeIn?: number; + easeOut?: number; } /** #153: whether this orbit glides. Absent is on; only `false` is off. */ @@ -29,6 +38,34 @@ export function glides(params: Pick): boolean { return params.glide !== false; } +/** Within 0..1; anything that is not a number reads as 0. */ +function clampAmount(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** Within 0..1, with anything that is not a number reading as `fallback`. */ +function amount(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return clampAmount(value); +} + +/** + * #175: the curve this orbit's travels take — the two amounts its owner + * set, or "steady" where they turned the motion off. One function, so a + * travel started from the ring, from a marker on it, or from a cue button + * cannot end up with a feel of its own. + */ +export function travelCurve( + params: Pick, +): TravelCurve { + if (!glides(params)) return "steady"; + return { + easeIn: amount(params.easeIn, 1), + easeOut: amount(params.easeOut, 1), + }; +} + /** * 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 @@ -243,24 +280,49 @@ 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. + * orbit whose owner turned the motion off still does. `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. + * + * #175: the eased shape is no longer one curve but the owner's two + * amounts. `{ easeIn: 1, easeOut: 1 }` is the smoothstep #153 shipped — + * still at both ends, fastest halfway — and each amount slides its own + * half of the travel towards a straight line, independently of the other. */ -export type TravelCurve = "steady" | "eased" | "slowing"; +export type TravelCurve = + "steady" | "slowing" | { easeIn: number; easeOut: number }; -// 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. +/** Smoothstep: still at both ends, fastest in the middle. */ +function smoothstep(progress: number): number { + return progress * progress * (3 - 2 * progress); +} + +// Every named member handled, 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 { + if (typeof curve === "object") { + // A blend, not a second formula: the eased shape and the straight line + // agree at the halfway point, so mixing each half towards the line by + // its own amount leaves the two halves meeting where they always did, + // and 1/1 IS the curve #153 shipped rather than an approximation of it. + // + // Where they meet, the POSITION is continuous; the speed is not. 100 + // in and 0 out changes pace at the halfway mark rather than at a frame + // boundary — a third of it, at the moment the travel is fastest, so it + // reads as part of the motion. It is a thing the owner chose, not a + // seam to hide. + // + // Clamped here as well as in travelCurve: the type is exported, and an + // amount outside 0..1 turns the blend back DOWN the path — `path[-3]`, + // a frame that is not a number, out of a function that promises one. + const towards = clampAmount(progress <= 0.5 ? curve.easeIn : curve.easeOut); + return progress + towards * (smoothstep(progress) - progress); + } 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);