feat: add the nav bar's lower-bezel wave canvas
A lazy chunk (journey/nav-wave.ts) that draws the bar's wave: a calm ribbon at cruise, a spectrum at speed, with a hue cycle near the top of the dial. It follows the chapter's accent and the dawn, repaints at once on resize, and draws one still frame under reduced motion. Budgeted as a deferred chunk.
This commit is contained in:
103
tests/unit/nav-wave.test.ts
Normal file
103
tests/unit/nav-wave.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createWaveModel } from '@/journey/nav-wave';
|
||||
|
||||
const run = (model: ReturnType<typeof createWaveModel>, seconds: number, speed: number, hue = -1): void => {
|
||||
for (let t = 0; t < seconds; t += 1 / 60) model.step(1 / 60, speed, hue);
|
||||
};
|
||||
const line = (model: ReturnType<typeof createWaveModel>, h: number, width = 1440, step = 3): number[] => {
|
||||
const ys: number[] = [];
|
||||
for (let x = 0; x <= width; x += step) ys.push(model.lineY(x, h));
|
||||
return ys;
|
||||
};
|
||||
|
||||
describe('nav bar wave', () => {
|
||||
it('never leaves its band, at any speed or band height', () => {
|
||||
// One expectation per band rather than per sample: half a million
|
||||
// expect() calls ran past the timeout on a loaded machine.
|
||||
for (const h of [18, 14]) {
|
||||
const model = createWaveModel();
|
||||
model.resize(1440);
|
||||
let low = Infinity;
|
||||
let high = -Infinity;
|
||||
for (const speed of [0, 0.37, 0.7, 1, 0.4, 1]) {
|
||||
for (let k = 0; k < 90; k += 1) {
|
||||
model.step(1 / 60, speed, -1);
|
||||
for (const y of line(model, h, 1440, 6)) {
|
||||
low = Math.min(low, y);
|
||||
high = Math.max(high, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(low).toBeGreaterThanOrEqual(1.6);
|
||||
expect(high).toBeLessThanOrEqual(h - 1.6);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a smooth ribbon at cruise that drifts rather than standing still', () => {
|
||||
const model = createWaveModel();
|
||||
model.resize(1440);
|
||||
run(model, 2, 0.37);
|
||||
expect(model.frame.spectrum).toBe(0);
|
||||
const before = line(model, 18);
|
||||
// Smooth: samples 3 px apart move by well under a pixel.
|
||||
for (let i = 1; i < before.length; i += 1) expect(Math.abs(before[i]! - before[i - 1]!)).toBeLessThan(0.6);
|
||||
// It travels: the highest crest moves along the bar within a second,
|
||||
// gently (no more than a third of the bar).
|
||||
const crest = (ys: number[]): number => ys.indexOf(Math.min(...ys)) * 3;
|
||||
const a = crest(before);
|
||||
run(model, 1, 0.37);
|
||||
const moved = Math.abs(crest(line(model, 18)) - a);
|
||||
expect(moved).toBeGreaterThan(6);
|
||||
expect(moved).toBeLessThan(480);
|
||||
});
|
||||
|
||||
it('is a spectrum flat out: peaks rising from the floor into the band', () => {
|
||||
const model = createWaveModel();
|
||||
model.resize(1440);
|
||||
run(model, 2, 1);
|
||||
expect(model.frame.spectrum).toBeGreaterThan(0.99);
|
||||
const h = 18;
|
||||
expect(model.axisY(h)).toBeGreaterThan(h - 2.5);
|
||||
const ys = line(model, h);
|
||||
const tall = ys.filter((y) => y < h / 2).length;
|
||||
expect(tall).toBeGreaterThan(ys.length / 5);
|
||||
// Well-defined peaks: many local maxima, not one blob.
|
||||
let peaks = 0;
|
||||
for (let i = 1; i < ys.length - 1; i += 1) if (ys[i]! < ys[i - 1]! && ys[i]! <= ys[i + 1]! && ys[i]! < h - 6) peaks += 1;
|
||||
expect(peaks).toBeGreaterThan(20);
|
||||
// Smooth, not blocky: no vertical cliffs between samples 3 px apart.
|
||||
for (let i = 1; i < ys.length; i += 1) expect(Math.abs(ys[i]! - ys[i - 1]!)).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it('follows a change of speed within a fraction of a second', () => {
|
||||
const model = createWaveModel();
|
||||
model.resize(800);
|
||||
run(model, 1, 0.37);
|
||||
run(model, 0.2, 1);
|
||||
expect(model.frame.speed).toBeGreaterThan(0.9);
|
||||
run(model, 0.2, 0.37);
|
||||
expect(model.frame.speed).toBeLessThan(0.45);
|
||||
});
|
||||
|
||||
it('brings the hue in over 1.2 s and lets it go over 4 s', () => {
|
||||
const model = createWaveModel();
|
||||
model.resize(800);
|
||||
run(model, 1.25, 1, 3);
|
||||
expect(model.frame.hueMix).toBe(1);
|
||||
run(model, 2, 0.4, 0);
|
||||
expect(model.frame.hueMix).toBeGreaterThan(0.1);
|
||||
expect(model.frame.hueMix).toBeLessThan(0.9);
|
||||
run(model, 2.1, 0.4, -1);
|
||||
expect(model.frame.hueMix).toBe(0);
|
||||
});
|
||||
|
||||
it('has a still frame for reduced motion', () => {
|
||||
const model = createWaveModel();
|
||||
model.resize(800);
|
||||
run(model, 1, 1, 3);
|
||||
const frame = model.still(1);
|
||||
expect(frame.hueMix).toBe(0);
|
||||
expect(frame.spectrum).toBe(0);
|
||||
expect(model.level(10)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user