diff --git a/src/components/Marquee.astro b/src/components/Marquee.astro index 93a2386..16b0091 100644 --- a/src/components/Marquee.astro +++ b/src/components/Marquee.astro @@ -2,26 +2,32 @@ interface Props { items: string[]; direction?: "normal" | "reverse"; - speed?: string; class?: string; + duration?: string; } -const { items, direction = "normal", speed = "75s", class: className = "" } = Astro.props; +const { items, direction = "normal", class: className = "", duration = "46s" } = Astro.props; const separator = direction === "normal" ? "❖" : "◆"; --- -
+
- {items.map((item) => ( - {item} - {separator} - ))} -
-
@@ -31,31 +37,40 @@ const separator = direction === "normal" ? "❖" : "◆"; display: flex; overflow: hidden; user-select: none; - gap: 4rem; height: 100%; align-items: center; + contain: content; } .marquee-content { - flex-shrink: 0; display: flex; align-items: center; - justify-content: space-around; - min-width: 100%; + justify-content: flex-start; + width: max-content; + min-width: max-content; height: 100%; - gap: 4rem; - animation: scroll var(--marquee-speed, 75s) linear infinite; white-space: nowrap; font-weight: bold; - letter-spacing: 1.5px; + letter-spacing: 0; text-shadow: 0 0 3px rgba(255, 51, 51, 0.4); line-height: 1; + will-change: transform; + animation: klbr-marquee-scroll var(--marquee-duration, 46s) linear infinite; } .marquee.reverse .marquee-content { - animation: scroll-reverse var(--marquee-speed, 90s) linear infinite; color: var(--klbr-alert, #e60012); text-shadow: 0 0 3px rgba(230, 0, 18, 0.4); + animation-direction: reverse; + } + + .marquee-group { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 4rem; + min-width: max-content; + padding-right: 4rem; } .marquee-content span { @@ -71,21 +86,20 @@ const separator = direction === "normal" ? "❖" : "◆"; color: #ff3333; } - @keyframes scroll { - 0% { - transform: translateX(0); + @keyframes klbr-marquee-scroll { + from { + transform: translate3d(0, 0, 0); } - 100% { - transform: translateX(calc(-100% - 4rem)); + to { + transform: translate3d(-50%, 0, 0); } } - @keyframes scroll-reverse { - 0% { - transform: translateX(calc(-100% - 4rem)); - } - 100% { - transform: translateX(0); + @media (prefers-reduced-motion: reduce) { + .marquee-content { + animation: none; + transform: none; + will-change: auto; } } diff --git a/src/components/RadioReceiver.svelte b/src/components/RadioReceiver.svelte index 111c8c1..ca4e808 100644 --- a/src/components/RadioReceiver.svelte +++ b/src/components/RadioReceiver.svelte @@ -5,13 +5,26 @@ const MIN_FREQ = 50.0; const MAX_FREQ = 250.0; const DEFAULT_FREQ = 163.000; - const DIAL_SCALE = 6; // Pixels per kHz unit const SCRAMBLE_RANGE = 5.0; const LOCK_SIGNAL_RANGE = 4.2; const LOCK_TOLERANCE = 0.85; const REQUIRED_LOCK_COUNT = 3; + const SPECTRUM_FRAME_MS = 100; + const SPECTRUM_RAF_LEAD_MS = 16; + const SCRAMBLE_FRAME_MS = 133; + const DIAGNOSTIC_TICK_MS = 1000; const NOISE_CHARS = "░▒▓█▖▗▘▙▚▛▜▝▞▟☠☣■□▲▼○●◆◇★☆✦✧ФЦЧШЩЪЫЬЭЮЯ0123456789%#@&*"; const NOISE_GLYPHS = Array.from(NOISE_CHARS); + const DIAL_TICKS = Array.from({ length: Math.ceil(MAX_FREQ - MIN_FREQ) + 1 }, (_, i) => MIN_FREQ + i); + const SCALE_LABELS = [50, 100, 150, 200, 250]; + const BIO_STATS = ["KOR", "ATR", "XTR", "P", "NP"]; + const DIAGNOSTIC_SCOPE_X = ["-100%", "-36%", "34%", "100%"]; + const DIAGNOSTIC_SCOPE_JITTER = [ + { x: "-1px", y: "1px" }, + { x: "1px", y: "-1px" }, + { x: "1px", y: "1px" }, + { x: "-1px", y: "-1px" }, + ]; const API_URL = typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") ? "http://127.0.0.1:8080" @@ -58,6 +71,8 @@ let feedbackMessage = $state(""); let currentTime = $state(new Date()); let diagnosticTick = $state(0); + let reducedMotion = $state(false); + let scrambleTick = $state(0); // Hardware toggles let brightness = $state("helle"); // helle (100%) | dimm (60%) | aus (20%) @@ -69,9 +84,6 @@ let lockFeedback = $state(""); let isAutoTuning = $state(false); - // Seed for visual scramble animation - let scrambleSeed = $state(0); - const serviceEndpoints = [ { code: "DROP", label: "File Upload Terminal", host: "drop.klbr.net", href: "https://drop.klbr.net" }, { code: "SLSH", label: "Slingshot Transport", host: "sl.klbr.net", href: "https://sl.klbr.net" }, @@ -106,10 +118,15 @@ return { x, y }; }); + let diagnosticPulseOpacity = $derived(diagnosticTick % 2 === 0 ? 0.72 : 1); + let diagnosticScopeX = $derived(DIAGNOSTIC_SCOPE_X[diagnosticTick % DIAGNOSTIC_SCOPE_X.length]); + let diagnosticScopeJitter = $derived(DIAGNOSTIC_SCOPE_JITTER[diagnosticTick % DIAGNOSTIC_SCOPE_JITTER.length]); + // References let canvasEl = $state(null); let dialEl = $state(null); let isDragging = $state(false); + let spectrumCtx: CanvasRenderingContext2D | null = null; // Web Audio Nodes let audioCtx: AudioContext | null = null; @@ -123,8 +140,10 @@ let masterGain: GainNode | null = null; // Animation & Interval IDs - let animationId = 0; - let scrambleInterval = 0; + let spectrumAnimationId = 0; + let spectrumTimerId = 0; + let lastSpectrumDrawAt = 0; + let scrambleTimerId = 0; let beepInterval = 0; let tunerAnimationId = 0; let lockFeedbackTimeout = 0; @@ -161,11 +180,39 @@ const clockInterval = setInterval(() => { currentTime = new Date(); diagnosticTick += 1; - }, 1000); + }, DIAGNOSTIC_TICK_MS); + const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updateMotionPreference = () => { + reducedMotion = motionQuery.matches; + if (reducedMotion) { + cancelSpectrumDraw(); + cancelScrambleTick(); + requestSpectrumDraw(); + } else { + requestSpectrumDraw(); + scheduleScrambleTick(); + } + }; + const handleVisibilityChange = () => { + if (document.hidden) { + cancelSpectrumDraw(); + cancelScrambleTick(); + return; + } + requestSpectrumDraw(); + scheduleScrambleTick(); + }; + + updateMotionPreference(); + motionQuery.addEventListener("change", updateMotionPreference); + document.addEventListener("visibilitychange", handleVisibilityChange); return () => { clearInterval(fetchInterval); clearInterval(clockInterval); + motionQuery.removeEventListener("change", updateMotionPreference); + document.removeEventListener("visibilitychange", handleVisibilityChange); + cancelScrambleTick(); }; }); @@ -233,6 +280,14 @@ return clampNumber(primarySignalStrength * 0.34, 0, 0.42); }); + let scrambleSeed = $derived.by(() => { + const signalId = transcriptionSignal?.id ?? 0; + const clarityBucket = Math.round(transcriptClarity * 1000); + return frequency * 13.37 + signalId * 0.73 + lockedSlotCount * 17 + clarityBucket * 0.11 + scrambleTick * 29.97; + }); + + let isScrambleActive = $derived(Boolean(transcriptionSignal && !showDataDump && transcriptClarity < 0.995)); + let decoderStatusText = $derived.by(() => { return signalStrength > 0.78 && !decoderLock ? "WARNING" : "NOMINAL"; }); @@ -735,30 +790,113 @@ wasSignalLocked = locked; }); + function cancelSpectrumDraw() { + if (spectrumTimerId) { + clearTimeout(spectrumTimerId); + spectrumTimerId = 0; + } + if (spectrumAnimationId) { + cancelAnimationFrame(spectrumAnimationId); + spectrumAnimationId = 0; + } + } + + function scheduleSpectrumFrame(delay = SPECTRUM_FRAME_MS) { + if (!canvasEl || !spectrumCtx || reducedMotion || document.hidden || spectrumTimerId || spectrumAnimationId) return; + + const timerDelay = Math.max(0, delay - SPECTRUM_RAF_LEAD_MS); + spectrumTimerId = window.setTimeout(() => { + spectrumTimerId = 0; + if (!canvasEl || !spectrumCtx || reducedMotion || document.hidden || spectrumAnimationId) return; + + spectrumAnimationId = requestAnimationFrame((now) => { + spectrumAnimationId = 0; + drawSpectrumFrame(now); + scheduleSpectrumFrame(); + }); + }, timerDelay); + } + + function requestSpectrumDraw() { + if (!canvasEl || !spectrumCtx || document.hidden || spectrumAnimationId) return; + if (spectrumTimerId) return; + + const elapsed = performance.now() - lastSpectrumDrawAt; + const delay = reducedMotion || elapsed >= SPECTRUM_FRAME_MS ? 0 : SPECTRUM_FRAME_MS - elapsed; + + if (delay > 0) { + scheduleSpectrumFrame(delay); + return; + } + + spectrumAnimationId = requestAnimationFrame((now) => { + spectrumAnimationId = 0; + drawSpectrumFrame(now); + scheduleSpectrumFrame(); + }); + } + + function cancelScrambleTick() { + if (scrambleTimerId) { + clearTimeout(scrambleTimerId); + scrambleTimerId = 0; + } + } + + function scheduleScrambleTick() { + if (!isScrambleActive || reducedMotion || document.hidden || scrambleTimerId) return; + + scrambleTimerId = window.setTimeout(() => { + scrambleTimerId = 0; + if (!isScrambleActive || reducedMotion || document.hidden) return; + scrambleTick += 1; + scheduleScrambleTick(); + }, SCRAMBLE_FRAME_MS); + } + // Canvas Spectrum waterfall / SDR visualizer $effect(() => { - if (canvasEl) { - const render = () => { - drawSpectrum(); - animationId = requestAnimationFrame(render); - }; - render(); - return () => cancelAnimationFrame(animationId); - } + if (!canvasEl) return; + + spectrumCtx = canvasEl.getContext("2d"); + if (!spectrumCtx) return; + + requestSpectrumDraw(); + + return () => { + cancelSpectrumDraw(); + spectrumCtx = null; + }; + }); + + $effect(() => { + canvasEl; + frequency; + broadcasts; + isAttenuated; + decoderLock; + requestSpectrumDraw(); }); - // Periodic Scramble Seed Updates $effect(() => { - scrambleInterval = window.setInterval(() => { - scrambleSeed = Math.random() * 1000; - }, 60); - return () => clearInterval(scrambleInterval); + if (!isScrambleActive || reducedMotion || document.hidden) { + cancelScrambleTick(); + return; + } + + scheduleScrambleTick(); + return () => cancelScrambleTick(); }); // Lo-Fi SDR Spectrum Visualizer Drawing (Vertical Bars) - function drawSpectrum() { - if (!canvasEl) return; - const ctx = canvasEl.getContext("2d"); + function drawSpectrumFrame(nowMs = performance.now()) { + lastSpectrumDrawAt = nowMs; + drawSpectrum(nowMs); + } + + function drawSpectrum(nowMs = performance.now()) { + if (!canvasEl || !spectrumCtx) return; + const ctx = spectrumCtx; if (!ctx) return; const width = canvasEl.width; @@ -781,7 +919,7 @@ const numBars = 36; const gap = 4; const barWidth = (width - gap * (numBars - 1)) / numBars; - const time = Date.now() * 0.008; + const time = nowMs * 0.008; for (let i = 0; i < numBars; i++) { // Find frequency of this bar relative to the tuned center frequency @@ -1206,7 +1344,8 @@ if (audioCtx) { audioCtx.close(); } - clearInterval(scrambleInterval); + cancelSpectrumDraw(); + cancelScrambleTick(); clearTimeout(beepInterval); if (tunerAnimationId) cancelAnimationFrame(tunerAnimationId); clearTimeout(lockFeedbackTimeout); @@ -1232,11 +1371,9 @@
- 50 - 100 - 150 - 200 - 250 + {#each SCALE_LABELS as label} + {label} + {/each}
@@ -1275,8 +1412,7 @@ {/if} @@ -1409,7 +1552,7 @@
- {#each ["KOR", "ATR", "XTR", "P", "NP"] as stat, i} + {#each BIO_STATS as stat, i}
{stat} @@ -1422,8 +1565,8 @@ {decoderStatusText}
- -