|
1 | 1 | <script lang="ts"> |
| 2 | + import { onDestroy, onMount } from 'svelte'; |
2 | 3 | import type { LiveShockerState } from '$lib/state/live-control-state.svelte'; |
3 | 4 |
|
4 | 5 | interface Props { |
|
10 | 11 | let { liveState, maxIntensity = 100, onRelease }: Props = $props(); |
11 | 12 |
|
12 | 13 | let container: HTMLDivElement | undefined = $state(); |
| 14 | + let canvas: HTMLCanvasElement | undefined = $state(); |
13 | 15 | let y = $state(1); |
14 | 16 |
|
15 | 17 | let intensity = $derived(Math.round((1 - y) * maxIntensity)); |
16 | 18 |
|
| 19 | + const WINDOW_MS = 3000; |
| 20 | + const SAMPLE_INTERVAL_MS = 16; |
| 21 | + const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS) + 2; |
| 22 | +
|
| 23 | + const SMOOTHING_TAU_MS = 50; |
| 24 | +
|
| 25 | + const samples: { t: number; v: number }[] = []; |
| 26 | + let smoothed = 0; |
| 27 | + let lastFrameAt = 0; |
| 28 | + let lastSampleAt = 0; |
| 29 | + let rafId = 0; |
| 30 | + let strokeColor = '#ffffff'; |
| 31 | +
|
| 32 | + function pushSample(now: number) { |
| 33 | + samples.push({ t: now, v: smoothed }); |
| 34 | + if (samples.length > MAX_SAMPLES) samples.shift(); |
| 35 | + lastSampleAt = now; |
| 36 | + } |
| 37 | +
|
| 38 | + function draw(now: number) { |
| 39 | + rafId = requestAnimationFrame(draw); |
| 40 | + if (!canvas) return; |
| 41 | +
|
| 42 | + const dt = lastFrameAt === 0 ? 0 : now - lastFrameAt; |
| 43 | + lastFrameAt = now; |
| 44 | + const alpha = 1 - Math.exp(-dt / SMOOTHING_TAU_MS); |
| 45 | + smoothed += (liveState.intensity - smoothed) * alpha; |
| 46 | +
|
| 47 | + if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) pushSample(now); |
| 48 | +
|
| 49 | + const dpr = window.devicePixelRatio || 1; |
| 50 | + const cssW = canvas.clientWidth; |
| 51 | + const cssH = canvas.clientHeight; |
| 52 | + const w = Math.round(cssW * dpr); |
| 53 | + const h = Math.round(cssH * dpr); |
| 54 | + if (canvas.width !== w || canvas.height !== h) { |
| 55 | + canvas.width = w; |
| 56 | + canvas.height = h; |
| 57 | + } |
| 58 | +
|
| 59 | + const ctx = canvas.getContext('2d'); |
| 60 | + if (!ctx) return; |
| 61 | + ctx.clearRect(0, 0, w, h); |
| 62 | + if (samples.length < 2) return; |
| 63 | +
|
| 64 | + const toX = (t: number) => w - ((now - t) / WINDOW_MS) * w; |
| 65 | + const toY = (v: number) => h - (Math.min(v, maxIntensity) / maxIntensity) * h; |
| 66 | +
|
| 67 | + ctx.beginPath(); |
| 68 | + const first = samples[0]; |
| 69 | + ctx.moveTo(toX(first.t), toY(first.v)); |
| 70 | + for (let i = 1; i < samples.length; i++) { |
| 71 | + const s = samples[i]; |
| 72 | + ctx.lineTo(toX(s.t), toY(s.v)); |
| 73 | + } |
| 74 | +
|
| 75 | + const gradient = ctx.createLinearGradient(0, 0, w, 0); |
| 76 | + gradient.addColorStop(0, 'transparent'); |
| 77 | + gradient.addColorStop(0.4, strokeColor); |
| 78 | + gradient.addColorStop(1, strokeColor); |
| 79 | +
|
| 80 | + ctx.lineJoin = 'round'; |
| 81 | + ctx.lineCap = 'round'; |
| 82 | +
|
| 83 | + // Outer blurred glow pass |
| 84 | + ctx.save(); |
| 85 | + ctx.lineWidth = 8 * dpr; |
| 86 | + ctx.strokeStyle = gradient; |
| 87 | + ctx.globalAlpha = 0.35; |
| 88 | + ctx.filter = `blur(${4 * dpr}px)`; |
| 89 | + ctx.stroke(); |
| 90 | + ctx.restore(); |
| 91 | +
|
| 92 | + // Crisp inner pass |
| 93 | + ctx.lineWidth = 4 * dpr; |
| 94 | + ctx.strokeStyle = gradient; |
| 95 | + ctx.stroke(); |
| 96 | + } |
| 97 | +
|
| 98 | + onMount(() => { |
| 99 | + strokeColor = getComputedStyle(canvas!).color || strokeColor; |
| 100 | + smoothed = liveState.intensity; |
| 101 | + pushSample(performance.now()); |
| 102 | + rafId = requestAnimationFrame(draw); |
| 103 | + }); |
| 104 | +
|
| 105 | + onDestroy(() => cancelAnimationFrame(rafId)); |
| 106 | +
|
17 | 107 | function startDrag(event: PointerEvent) { |
18 | 108 | if (!container) return; |
19 | 109 | liveState.isDragging = true; |
|
72 | 162 | <div class="relative h-full w-full p-4 select-none"> |
73 | 163 | <div |
74 | 164 | bind:this={container} |
75 | | - class="border-border relative h-full w-full cursor-pointer overflow-hidden rounded-md border" |
| 165 | + class="relative h-full w-full cursor-pointer" |
76 | 166 | onpointerdown={startDrag} |
77 | 167 | onpointermove={onPointerMove} |
78 | 168 | onpointerup={stopDrag} |
|
85 | 175 | aria-label="Live intensity" |
86 | 176 | tabindex="0" |
87 | 177 | > |
88 | | - <!-- Fill from bottom --> |
89 | | - <div |
90 | | - class="bg-muted pointer-events-none absolute bottom-0 left-0 w-full transition-none" |
91 | | - style="height: {(1 - y) * 100}%" |
92 | | - ></div> |
| 178 | + <!-- Intensity-history graph: newest on the right, slides smoothly left over time --> |
| 179 | + <div class="border-border absolute inset-0 overflow-hidden rounded-md border"> |
| 180 | + <canvas |
| 181 | + bind:this={canvas} |
| 182 | + class="text-primary pointer-events-none absolute inset-0 h-full w-full" |
| 183 | + aria-hidden="true" |
| 184 | + ></canvas> |
| 185 | + </div> |
93 | 186 |
|
94 | | - <!-- Handle --> |
| 187 | + <!-- Handle (outside the clipped rectangle so text can overflow) --> |
95 | 188 | <div |
96 | 189 | class="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full transition-none |
97 | 190 | {liveState.isDragging |
|
0 commit comments