fix(motion): stop a long departure from playing for seventeen seconds

Exit timing was linear in path length with no ceiling, so the longest route in
the shipped set animated for 17.4s and 19% of all departures ran past two
seconds. MOTION already carried exitMaxTravelMs and exitLongMaxMs for this;
nothing read them.

Below the knee nothing changes — every piece still cruises at the same speed,
which is the property the old test protected and the reason motion reads as
physical. Past it the piece accelerates rather than taking longer, approaching
a ceiling. Length is still legible: longer routes still play longer, just with
a compressing tail.

The test now states the rule it is protecting instead of "no duration cap":
constant speed below the knee, a bounded and monotone tail above it.

labs/exit-timing plays the same real route side by side, uncapped against a
candidate setting, with knee and ceiling on sliders — the numbers say 3.5s but
only watching it says whether that is right. Defaults are provisional at
2.4s / 3.2s pending that call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 16:41:06 +08:00
co-authored by Claude Opus 5
parent acc374e276
commit a250baba53
4 changed files with 261 additions and 8 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
});
export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11});
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,rotationMs:240,exitMsPerUnit:120,exitRampMs:80,exitMinTravelMs:140,exitMaxTravelMs:800,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:1700,exitLongStartCells:2,exitLongFullCells:5});
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,rotationMs:240,exitMsPerUnit:120,exitRampMs:80,exitMinTravelMs:140,exitMaxTravelMs:2400,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:3200,exitLongStartCells:2,exitLongFullCells:5});
export const RADII = Object.freeze({control:14,dialog:24,small:8});
export function luminance(hex){return hex.slice(1,7).match(/../g).map(v=>parseInt(v,16)/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((a,v,i)=>a+v*[.2126,.7152,.0722][i],0);}
export function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);}
+14 -5
View File
@@ -1,14 +1,23 @@
import { MOTION } from './design-tokens.js';
import { routePoints } from './geometry.js';
// Timing follows actual path length, so a piece beside an exit has no fixed wait.
export function exitTiming(route) {
// Timing follows actual path length, so a piece beside an exit has no fixed
// wait. Cruising speed is one constant for every piece — until the knee. Past
// it the piece accelerates instead of taking longer, because uncapped linear
// timing ran a single departure to seventeen seconds and reads as a stall
// rather than as a journey. Callers may override the ceiling to compare
// settings; labs/exit-timing does exactly that.
export function exitTiming(route,{knee=MOTION.exitMaxTravelMs,maxMs=MOTION.exitLongMaxMs}={}) {
const points=routePoints({...route,end:null},{size:1,ox:0,oy:0});
const distance=points.slice(1).reduce((sum,p,i)=>sum+Math.hypot(p.x-points[i].x,p.y-points[i].y),0);
const rampMs=Math.min(MOTION.exitRampMs,distance*MOTION.exitMsPerUnit/3);
const travelMs=distance*MOTION.exitMsPerUnit+rampMs;
const linear=distance*MOTION.exitMsPerUnit;
// Below the knee nothing changes: same speed as before, same feel.
const base=linear<=knee?linear
:Math.min(maxMs,knee+(linear-knee)*Math.max(0,(maxMs-knee))/Math.max(1,linear-knee)*(1-Math.exp(-(linear-knee)/Math.max(1,maxMs-knee))));
const rampMs=Math.min(MOTION.exitRampMs,base/3);
const travelMs=base+rampMs;
const duration=travelMs+MOTION.exitEffectMs;
return {duration,travelMs,distance,travelFraction:travelMs/duration};
return {duration,travelMs,distance,travelFraction:travelMs/duration,capped:linear>knee};
}
// Zero endpoint velocity: a soft launch and a settled finish, without overshoot.