+
+
+
+
diff --git a/src/design-tokens.js b/src/design-tokens.js
index 0f3ca19..cea1958 100644
--- a/src/design-tokens.js
+++ b/src/design-tokens.js
@@ -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);}
diff --git a/src/exit-animation.js b/src/exit-animation.js
index 73df305..3b4175d 100644
--- a/src/exit-animation.js
+++ b/src/exit-animation.js
@@ -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.
diff --git a/tests/themes.test.js b/tests/themes.test.js
index 25ebc8e..73dc8fd 100644
--- a/tests/themes.test.js
+++ b/tests/themes.test.js
@@ -52,9 +52,11 @@ test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
});
-test('curved and long routes share the same cruising speed without a duration cap',async()=>{
+test('routes below the knee cruise at one speed; longer ones accelerate instead of dragging',async()=>{
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
- for(const distance of [1,5,15,30]){
+ // Below the knee the old guarantee still holds exactly: same cruising speed
+ // for every piece, no cap. distance*120 stays under 2400ms up to distance 20.
+ for(const distance of [1,5,15]){
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40));
const t=timing.travelMs*.5,dt=1;
@@ -62,6 +64,21 @@ test('curved and long routes share the same cruising speed without a duration ca
assert.ok(Math.abs(speed-1/120)<1e-9);
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9);
+ assert.equal(timing.capped,false);
+ }
+ // Past the knee the piece speeds up so the journey stays watchable. Uncapped
+ // linear timing put a single departure at seventeen seconds.
+ const long=exitTiming({travelPoints:[{x:0,y:0},{x:150,y:0}],steps:[]});
+ assert.equal(long.capped,true);
+ assert.ok(long.duration<4200,`一条超长路线不该超过 4.2s,实际 ${long.duration}`);
+ assert.ok(long.duration>exitTiming({travelPoints:[{x:0,y:0},{x:20,y:0}],steps:[]}).duration,
+ '再长的路线仍然要比中等路线久,长度感不能丢');
+ // Monotone: a longer route never plays shorter than a shorter one.
+ let prev=0;
+ for(const d of [1,5,15,25,40,80,150,400]){
+ const ms=exitTiming({travelPoints:[{x:0,y:0},{x:d,y:0}],steps:[]}).duration;
+ assert.ok(ms>=prev,`距离 ${d} 的时长 ${ms} 不该短于更短的路线 ${prev}`);
+ prev=ms;
}
});