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}
- ))}
-
-
- {items.map((item) => (
-
{item}
-
{separator}
- ))}
+
+ {items.map((item) => (
+ <>
+ {item}
+ {separator}
+ >
+ ))}
+
+
+ {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 @@
@@ -1275,8 +1412,7 @@
{/if}
- {#each Array(Math.ceil(MAX_FREQ - MIN_FREQ) + 1) as _, i}
- {@const f = MIN_FREQ + i}
+ {#each DIAL_TICKS as f}
{/each}
@@ -1326,8 +1462,8 @@
{:else if transcriptionSignal}
{@const scrambledCall = scrambleText(transcriptionSignal.callsign, transcriptClarity, scrambleSeed)}
{@const scrambledTextVal = scrambleText(transcriptionSignal.text, transcriptClarity, scrambleSeed)}
-
{scrambledTextVal}
-
{scrambledCall}
+
{scrambledTextVal}
+
{scrambledCall}
{#if lockReadout}
{lockReadout}
{/if}
@@ -1401,7 +1537,14 @@
-
+
FRAGMENTED
@@ -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}
-
-
+
+
@@ -1435,694 +1578,9 @@
- {#if false}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {frequency.toFixed(3)}
- KHZ
-
-
-
-
- {#each Array(8) as _, i}
- {@const threshold = (i + 1) / 8}
-
= threshold}>
- {/each}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {#each Array(Math.ceil(MAX_FREQ - MIN_FREQ) + 1) as _, i}
- {@const f = MIN_FREQ + i}
-
- {#if f % 10 === 0}
- {f.toFixed(0)}
- {/if}
-
- {/each}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {#if showDataDump}
-
- 「DATA CACHE // DISCOVERED SIGNALS: {decoderRecords.length}
- {#if decoderRecords.length === 0}
- NO LOCKED CARRIERS
- {:else}
- {#each decoderRecords as record}
- {@const recordLockedCount = getLockedCount(record)}
- {record.signal.callsign} // LOCK {recordLockedCount}/{REQUIRED_LOCK_COUNT}
- {#each record.frequencies as lockFrequency, i}
- {#if record.lockedSlots[i]}
- {i === 0 ? "CARRIER" : "AUX-" + i} {lockFrequency.toFixed(3)} KHZ
- {/if}
- {/each}
- {#if recordLockedCount >= REQUIRED_LOCK_COUNT}
- MSG: {record.signal.text}
- {:else}
- MSG: UNDECIPHERED
- {/if}
- {/each}
- {/if}
- 」
-
- {:else if transcriptionSignal}
- {@const scrambledCall = scrambleText(transcriptionSignal.callsign, transcriptClarity, scrambleSeed)}
- {@const scrambledTextVal = scrambleText(transcriptionSignal.text, transcriptClarity, scrambleSeed)}
-
- 「SNDR: {scrambledCall} // FREQ: {transcriptionSignal.frequency.toFixed(3)} KHZ
- {scrambledTextVal}
- {#if lockReadout}
{lockReadout}{/if}」
-
- {:else}
-
- 「NO DATA FEED」
-
- {/if}
-
-
-
-
-
-
-
- {#if feedbackMessage}
-
- {feedbackMessage}
-
- {/if}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/if}
-