Files
pipu-music/apps/web/src/widgets/wave/draw.ts
T
mrleo1nidandClaude Opus 5.5 eaedd1fc62
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s
feat(web): My Wave button, mood picker and endless wave playback (M4)
- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:29:38 +03:00

159 lines
5.9 KiB
TypeScript

// Отрисовка кнопки «Моя волна» на Canvas 2D — порт прототипа docs/Разработка дизайна приложения/wave.js
// (варианты orb и liquid; «Кольца» не реализуем — docs/design.md).
export type Rgb = [number, number, number];
export interface WaveState {
hover: number;
hoverT: number;
press: number;
pressV: number;
pressT: number;
mx: number;
my: number;
ang: number;
t: number;
amp: number;
pulse: number;
speed: number;
phase: number;
variant: 'orb' | 'liquid';
playing: boolean;
bpm: number;
energy: number;
colors: [string, string, string];
dark: boolean;
reduced: boolean;
size: number;
}
export function rgb(hex: string): Rgb {
const h = hex.replace('#', '');
const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (c: Rgb, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a: Rgb, b: Rgb, k: number): Rgb => [
Math.round(a[0] + (b[0] - a[0]) * k),
Math.round(a[1] + (b[1] - a[1]) * k),
Math.round(a[2] + (b[2] - a[2]) * k),
];
export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
const [c1, c2, c3] = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
const glow = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.65);
glow.addColorStop(0, rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12));
glow.addColorStop(0.5, rgba(c2, 0.1 + s.pulse * 0.12));
glow.addColorStop(1, rgba(c1, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.65, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = s.dark ? '#060B1C' : '#0B1533';
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.globalCompositeOperation = 'lighter';
const blobs = [c1, c2, c3, c1, c2];
blobs.forEach((c, i) => {
const a = t * (0.25 + i * 0.07) + i * 1.7;
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
g.addColorStop(0, rgba(c, 0.55));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
});
const lines = 9;
for (let l = 0; l < lines; l++) {
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
ctx.beginPath();
for (let x = -r; x <= r; x += 4) {
const y = yy + Math.sin((x / r) * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos((x / r) * 1.4);
if (x === -r) ctx.moveTo(cx + x, y);
else ctx.lineTo(cx + x, y);
}
ctx.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
const hl = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.9);
hl.addColorStop(0, 'rgba(255,255,255,0.18)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hl;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3);
ctx.lineWidth = 1;
ctx.stroke();
}
export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6);
glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3));
glow.addColorStop(1, rgba(cols[0], 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.6, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
for (let L = 0; L < 3; L++) {
ctx.beginPath();
const P = 140;
for (let j = 0; j <= P; j++) {
const th = (j / P) * Math.PI * 2;
const w =
1 +
0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2) +
0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) +
0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
0.08 * s.pulse * Math.sin(2 * th + L) +
0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
const rr = r * (0.86 - L * 0.07) * w;
const x = cx + Math.cos(th) * rr;
const y = cy + Math.sin(th) * rr;
if (j) ctx.lineTo(x, y);
else ctx.moveTo(x, y);
}
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
g.addColorStop(0, rgba(mix(cols[L]!, [255, 255, 255], 0.25), 0.85));
g.addColorStop(1, rgba(cols[L]!, 0.25));
ctx.fillStyle = g;
ctx.globalAlpha = s.dark ? 0.6 : 0.75;
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
/** Шаг физики: пружины hover / press, амплитуда при игре, пульс в такт BPM (docs/design.md#3). */
export function step(s: WaveState, dt: number) {
s.hover += (s.hoverT - s.hover) * 0.08;
s.pressV += (s.pressT - s.press) * 0.35;
s.pressV *= 0.62;
s.press += s.pressV;
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
s.t += dt * s.speed;
if (s.playing && !s.reduced) {
s.phase = (s.phase + (dt * s.bpm) / 60) % 1;
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
} else {
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
}
}