diff --git a/website/src/_layouts/base.njk b/website/src/_layouts/base.njk index 1bd7770..75b6711 100644 --- a/website/src/_layouts/base.njk +++ b/website/src/_layouts/base.njk @@ -81,5 +81,10 @@ + + {% if heroFx %} + + {% endif %} + \ No newline at end of file diff --git a/website/src/assets/css/main.css b/website/src/assets/css/main.css index b1956f6..bf93ea2 100644 --- a/website/src/assets/css/main.css +++ b/website/src/assets/css/main.css @@ -7,10 +7,22 @@ --text: #f8fafc; --muted: #aab6ca; --soft: #64748b; - --accent: #38bdf8; - --accent2: #a855f7; - --accent3: #22c55e; + /*--accent: #38bdf8;*/ + /*--accent2: #a855f7;*/ + /*--accent3: #22c55e;*/ --shadow: 0 24px 80px rgba(0, 0, 0, 0.45); + + --accent: #54d63d; + --accent2: #8aef64; + --accent3: #31b54a; + + --fx-plane: #79ff73; + --fx-plane-muted: rgba(121, 255, 115, 0.42); + --fx-beam: #52ff65; + --fx-beam-glow: rgba(82, 255, 101, 0.28); + --fx-reflection: #d2ffc8; + --fx-trail: rgba(121, 255, 115, 0.17); + --fx-grid: rgba(121, 255, 115, 0.055); } * { @@ -25,13 +37,39 @@ html { body { margin: 0; min-height: 100vh; + background: - radial-gradient(circle at 14% 12%, rgba(168, 85, 247, 0.30), transparent 28%), - radial-gradient(circle at 82% 16%, rgba(56, 189, 248, 0.24), transparent 30%), - radial-gradient(circle at 50% 85%, rgba(34, 197, 94, 0.11), transparent 35%), - linear-gradient(180deg, #050816 0%, #07111f 100%); + radial-gradient( + circle at 14% 12%, + rgba(52, 165, 57, 0.20), + transparent 30% + ), + radial-gradient( + circle at 82% 16%, + rgba(95, 218, 82, 0.14), + transparent 32% + ), + radial-gradient( + circle at 50% 85%, + rgba(32, 126, 55, 0.12), + transparent 36% + ), + linear-gradient( + 180deg, + #070b09 0%, + #0b1410 100% + ); + color: var(--text); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + line-height: 1.65; overflow-x: hidden; } @@ -561,4 +599,156 @@ a:hover { .footer-grid { grid-template-columns: 1fr; } +} + +/* + * Interactive radio / aircraft-scatter hero layer + */ + +.hero-radio-fx { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0.76; +} + +.hero-fx-vignette { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + + background: + linear-gradient( + 90deg, + rgba(7, 11, 9, 0.92) 0%, + rgba(7, 11, 9, 0.68) 34%, + rgba(7, 11, 9, 0.16) 64%, + rgba(7, 11, 9, 0.42) 100% + ), + linear-gradient( + 180deg, + rgba(7, 11, 9, 0.10), + rgba(7, 11, 9, 0.58) + ); +} + +.hero-split > *:not(.hero-radio-fx):not(.hero-fx-vignette) { + position: relative; + z-index: 2; +} + +/* + * Operator-console badge styling + */ + +.badge, +.eyebrow { + color: #caffc0; + background: rgba(52, 165, 57, 0.12); + border-color: rgba(105, 232, 88, 0.32); + box-shadow: 0 0 22px rgba(82, 255, 101, 0.08); +} + +/* + * Light-mode identity: + * white/grey UI with red contest accents + */ + +html[data-theme="light"] { + --bg: #f4f4f2; + --bg2: #ffffff; + --panel: rgba(255, 255, 255, 0.88); + --panel2: rgba(242, 242, 239, 0.92); + --border: rgba(91, 91, 86, 0.20); + --text: #252522; + --muted: #656560; + --soft: #888881; + + --accent: #d33732; + --accent2: #f25a51; + --accent3: #ae2727; + + --fx-plane: #df3732; + --fx-plane-muted: rgba(211, 55, 50, 0.38); + --fx-beam: #ef4039; + --fx-beam-glow: rgba(239, 64, 57, 0.22); + --fx-reflection: #ff8d84; + --fx-trail: rgba(211, 55, 50, 0.15); + --fx-grid: rgba(185, 47, 43, 0.045); +} + +html[data-theme="light"] body { + background: + radial-gradient( + circle at 14% 12%, + rgba(211, 55, 50, 0.12), + transparent 30% + ), + radial-gradient( + circle at 82% 16%, + rgba(242, 90, 81, 0.09), + transparent 32% + ), + linear-gradient( + 180deg, + #ffffff 0%, + #f0f0ed 100% + ); +} + +html[data-theme="light"] .hero-fx-vignette { + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.95) 0%, + rgba(255, 255, 255, 0.76) 34%, + rgba(255, 255, 255, 0.18) 66%, + rgba(255, 255, 255, 0.48) 100% + ), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.06), + rgba(240, 240, 237, 0.62) + ); +} + +html[data-theme="light"] .badge, +html[data-theme="light"] .eyebrow { + color: #962a27; + background: rgba(211, 55, 50, 0.08); + border-color: rgba(211, 55, 50, 0.24); +} + +@media (max-width: 900px) { + .hero-radio-fx { + opacity: 0.48; + } + + .hero-fx-vignette { + background: + linear-gradient( + 180deg, + rgba(7, 11, 9, 0.82), + rgba(7, 11, 9, 0.34) + ); + } + + html[data-theme="light"] .hero-fx-vignette { + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.90), + rgba(255, 255, 255, 0.42) + ); + } +} + +@media (prefers-reduced-motion: reduce) { + .hero-radio-fx { + opacity: 0.28; + } } \ No newline at end of file diff --git a/website/src/assets/js/hero-radio-fx.js b/website/src/assets/js/hero-radio-fx.js new file mode 100644 index 0000000..b83644f --- /dev/null +++ b/website/src/assets/js/hero-radio-fx.js @@ -0,0 +1,710 @@ +(() => { + "use strict"; + + const canvas = document.querySelector("[data-hero-radio-fx]"); + + if (!(canvas instanceof HTMLCanvasElement)) { + return; + } + + const context = canvas.getContext("2d", { + alpha: true, + desynchronized: true + }); + + if (!context) { + return; + } + + const hero = canvas.closest(".hero"); + + if (!(hero instanceof HTMLElement)) { + return; + } + + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ); + + const coarsePointer = window.matchMedia( + "(pointer: coarse)" + ); + + const state = { + width: 0, + height: 0, + dpr: 1, + running: false, + visible: true, + lastFrame: 0, + spawnAccumulator: 0, + beamAccumulator: 0, + animationFrameId: 0, + pointer: { + x: 0, + y: 0, + active: false + }, + planes: [], + beams: [], + reflections: [] + }; + + const clamp = (value, minimum, maximum) => + Math.max(minimum, Math.min(maximum, value)); + + const randomBetween = (minimum, maximum) => + minimum + Math.random() * (maximum - minimum); + + function getCssColor(name, fallback) { + const value = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + + return value || fallback; + } + + function getPalette() { + return { + plane: getCssColor("--fx-plane", "#79ff73"), + planeMuted: getCssColor("--fx-plane-muted", "rgba(121,255,115,.42)"), + beam: getCssColor("--fx-beam", "#52ff65"), + beamGlow: getCssColor("--fx-beam-glow", "rgba(82,255,101,.28)"), + reflection: getCssColor("--fx-reflection", "#c8ffbf"), + trail: getCssColor("--fx-trail", "rgba(121,255,115,.16)"), + grid: getCssColor("--fx-grid", "rgba(121,255,115,.055)") + }; + } + + function resizeCanvas() { + const bounds = hero.getBoundingClientRect(); + + state.width = Math.max(1, Math.round(bounds.width)); + state.height = Math.max(1, Math.round(bounds.height)); + state.dpr = Math.min(window.devicePixelRatio || 1, 1.5); + + canvas.width = Math.round(state.width * state.dpr); + canvas.height = Math.round(state.height * state.dpr); + + canvas.style.width = `${state.width}px`; + canvas.style.height = `${state.height}px`; + + context.setTransform( + state.dpr, + 0, + 0, + state.dpr, + 0, + 0 + ); + + if (!state.pointer.active) { + state.pointer.x = state.width * 0.54; + state.pointer.y = state.height * 0.42; + } + } + + function getPlaneLimit() { + if (coarsePointer.matches) { + return 5; + } + + if (state.width < 900) { + return 7; + } + + return 12; + } + + function createPlane() { + const margin = 55; + const edge = Math.floor(Math.random() * 4); + + let x; + let y; + + switch (edge) { + case 0: + x = randomBetween(0, state.width); + y = -margin; + break; + + case 1: + x = state.width + margin; + y = randomBetween(0, state.height); + break; + + case 2: + x = randomBetween(0, state.width); + y = state.height + margin; + break; + + default: + x = -margin; + y = randomBetween(0, state.height); + break; + } + + const targetX = state.pointer.active + ? state.pointer.x + : state.width * randomBetween(0.38, 0.72); + + const targetY = state.pointer.active + ? state.pointer.y + : state.height * randomBetween(0.2, 0.72); + + const angle = Math.atan2(targetY - y, targetX - x); + const speed = randomBetween(18, 34); + + return { + x, + y, + previousX: x, + previousY: y, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + angle, + size: randomBetween(5.5, 9), + age: 0, + maximumAge: randomBetween(12, 20), + steering: randomBetween(0.65, 1.2), + wobble: randomBetween(0, Math.PI * 2), + wobbleSpeed: randomBetween(0.7, 1.4), + hit: 0 + }; + } + + function updatePlane(plane, deltaSeconds) { + plane.age += deltaSeconds; + plane.wobble += deltaSeconds * plane.wobbleSpeed; + + plane.previousX = plane.x; + plane.previousY = plane.y; + + const targetX = state.pointer.active + ? state.pointer.x + : state.width * 0.56; + + const targetY = state.pointer.active + ? state.pointer.y + : state.height * 0.44; + + const desiredAngle = Math.atan2( + targetY - plane.y, + targetX - plane.x + ); + + let angleDifference = desiredAngle - plane.angle; + + while (angleDifference > Math.PI) { + angleDifference -= Math.PI * 2; + } + + while (angleDifference < -Math.PI) { + angleDifference += Math.PI * 2; + } + + plane.angle += angleDifference * plane.steering * deltaSeconds; + + const speed = Math.hypot( + plane.velocityX, + plane.velocityY + ); + + const wobble = Math.sin(plane.wobble) * 0.14; + + plane.velocityX = + Math.cos(plane.angle + wobble) * speed; + + plane.velocityY = + Math.sin(plane.angle + wobble) * speed; + + plane.x += plane.velocityX * deltaSeconds; + plane.y += plane.velocityY * deltaSeconds; + + plane.hit = Math.max(0, plane.hit - deltaSeconds * 1.8); + } + + function isPlaneExpired(plane) { + const margin = 180; + + return ( + plane.age > plane.maximumAge || + plane.x < -margin || + plane.x > state.width + margin || + plane.y < -margin || + plane.y > state.height + margin + ); + } + + function chooseBeamTarget() { + if (state.planes.length === 0) { + return null; + } + + const targetX = state.pointer.active + ? state.pointer.x + : state.width * 0.56; + + const targetY = state.pointer.active + ? state.pointer.y + : state.height * 0.44; + + const candidates = state.planes + .map((plane) => ({ + plane, + distance: Math.hypot( + plane.x - targetX, + plane.y - targetY + ) + })) + .filter((entry) => entry.distance < 260) + .sort((a, b) => a.distance - b.distance); + + if (candidates.length > 0) { + return candidates[0].plane; + } + + return state.planes[ + Math.floor(Math.random() * state.planes.length) + ]; + } + + function createBeam(target) { + const leftStation = { + x: state.width * 0.12, + y: state.height * 0.82 + }; + + const rightStation = { + x: state.width * 0.88, + y: state.height * 0.78 + }; + + const source = + Math.abs(target.x - leftStation.x) < + Math.abs(target.x - rightStation.x) + ? leftStation + : rightStation; + + state.beams.push({ + startX: source.x, + startY: source.y, + endX: target.x, + endY: target.y, + age: 0, + maximumAge: 0.62 + }); + + target.hit = 1; + + state.reflections.push({ + x: target.x, + y: target.y, + age: 0, + maximumAge: 0.9, + radius: randomBetween(8, 15) + }); + } + + function updateEffects(deltaSeconds) { + for (const beam of state.beams) { + beam.age += deltaSeconds; + } + + state.beams = state.beams.filter( + (beam) => beam.age < beam.maximumAge + ); + + for (const reflection of state.reflections) { + reflection.age += deltaSeconds; + reflection.radius += deltaSeconds * 30; + } + + state.reflections = state.reflections.filter( + (reflection) => + reflection.age < reflection.maximumAge + ); + } + + function drawGrid(palette) { + context.save(); + + context.strokeStyle = palette.grid; + context.lineWidth = 1; + + const spacing = 54; + + for ( + let x = spacing; + x < state.width; + x += spacing + ) { + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, state.height); + context.stroke(); + } + + for ( + let y = spacing; + y < state.height; + y += spacing + ) { + context.beginPath(); + context.moveTo(0, y); + context.lineTo(state.width, y); + context.stroke(); + } + + context.restore(); + } + + function drawRadarTarget(palette) { + const x = state.pointer.active + ? state.pointer.x + : state.width * 0.56; + + const y = state.pointer.active + ? state.pointer.y + : state.height * 0.44; + + context.save(); + + context.strokeStyle = palette.planeMuted; + context.lineWidth = 1; + + for (const radius of [24, 48, 82]) { + context.globalAlpha = 0.24 - radius / 500; + + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.stroke(); + } + + context.globalAlpha = 0.2; + + context.beginPath(); + context.moveTo(x - 100, y); + context.lineTo(x + 100, y); + context.moveTo(x, y - 100); + context.lineTo(x, y + 100); + context.stroke(); + + context.restore(); + } + + function drawPlane(plane, palette) { + context.save(); + + context.translate(plane.x, plane.y); + context.rotate(plane.angle); + + const alpha = clamp( + Math.min( + plane.age * 1.8, + (plane.maximumAge - plane.age) * 1.8 + ), + 0, + 1 + ); + + context.globalAlpha = alpha; + + context.strokeStyle = palette.trail; + context.lineWidth = 1; + + context.beginPath(); + context.moveTo(-plane.size * 4.5, 0); + context.lineTo(-plane.size * 1.4, 0); + context.stroke(); + + context.fillStyle = + plane.hit > 0 + ? palette.reflection + : palette.plane; + + context.shadowColor = + plane.hit > 0 + ? palette.reflection + : palette.beamGlow; + + context.shadowBlur = + plane.hit > 0 + ? 16 + : 5; + + const size = plane.size; + + context.beginPath(); + + context.moveTo(size * 1.65, 0); + context.lineTo(-size * 0.7, size * 0.34); + context.lineTo(-size * 0.15, size * 1.05); + context.lineTo(-size * 0.65, size * 1.08); + context.lineTo(-size * 1.35, size * 0.3); + context.lineTo(-size * 1.7, size * 0.28); + context.lineTo(-size * 1.12, 0); + context.lineTo(-size * 1.7, -size * 0.28); + context.lineTo(-size * 1.35, -size * 0.3); + context.lineTo(-size * 0.65, -size * 1.08); + context.lineTo(-size * 0.15, -size * 1.05); + context.lineTo(-size * 0.7, -size * 0.34); + + context.closePath(); + context.fill(); + + context.restore(); + } + + function drawBeam(beam, palette) { + const progress = beam.age / beam.maximumAge; + const alpha = Math.sin(progress * Math.PI); + + context.save(); + + context.globalAlpha = alpha; + context.strokeStyle = palette.beamGlow; + context.lineWidth = 6; + context.shadowColor = palette.beam; + context.shadowBlur = 15; + + context.beginPath(); + context.moveTo(beam.startX, beam.startY); + context.lineTo(beam.endX, beam.endY); + context.stroke(); + + context.strokeStyle = palette.beam; + context.lineWidth = 1.4; + context.shadowBlur = 5; + + context.beginPath(); + context.moveTo(beam.startX, beam.startY); + context.lineTo(beam.endX, beam.endY); + context.stroke(); + + context.restore(); + } + + function drawReflection(reflection, palette) { + const progress = + reflection.age / reflection.maximumAge; + + context.save(); + + context.globalAlpha = 1 - progress; + context.strokeStyle = palette.reflection; + context.lineWidth = 1.5; + context.shadowColor = palette.beam; + context.shadowBlur = 12; + + context.beginPath(); + context.arc( + reflection.x, + reflection.y, + reflection.radius, + 0, + Math.PI * 2 + ); + context.stroke(); + + context.restore(); + } + + function drawStaticScene() { + const palette = getPalette(); + + context.clearRect( + 0, + 0, + state.width, + state.height + ); + + drawGrid(palette); + drawRadarTarget(palette); + } + + function drawScene() { + const palette = getPalette(); + + context.clearRect( + 0, + 0, + state.width, + state.height + ); + + drawGrid(palette); + drawRadarTarget(palette); + + for (const plane of state.planes) { + drawPlane(plane, palette); + } + + for (const beam of state.beams) { + drawBeam(beam, palette); + } + + for (const reflection of state.reflections) { + drawReflection(reflection, palette); + } + } + + function animate(timestamp) { + if (!state.running) { + return; + } + + const deltaSeconds = clamp( + (timestamp - state.lastFrame) / 1000 || 0, + 0, + 0.05 + ); + + state.lastFrame = timestamp; + + if (!state.visible || document.hidden) { + state.animationFrameId = + requestAnimationFrame(animate); + + return; + } + + state.spawnAccumulator += deltaSeconds; + state.beamAccumulator += deltaSeconds; + + const planeLimit = getPlaneLimit(); + const spawnInterval = + coarsePointer.matches ? 2.2 : 1.15; + + if ( + state.spawnAccumulator >= spawnInterval && + state.planes.length < planeLimit + ) { + state.spawnAccumulator = 0; + state.planes.push(createPlane()); + } + + const beamInterval = + coarsePointer.matches ? 3.3 : 1.8; + + if ( + state.beamAccumulator >= beamInterval && + state.planes.length > 0 + ) { + state.beamAccumulator = 0; + + const target = chooseBeamTarget(); + + if (target) { + createBeam(target); + } + } + + for (const plane of state.planes) { + updatePlane(plane, deltaSeconds); + } + + state.planes = state.planes.filter( + (plane) => !isPlaneExpired(plane) + ); + + updateEffects(deltaSeconds); + drawScene(); + + state.animationFrameId = + requestAnimationFrame(animate); + } + + function start() { + cancelAnimationFrame(state.animationFrameId); + + resizeCanvas(); + + if (reducedMotion.matches) { + state.running = false; + drawStaticScene(); + return; + } + + state.running = true; + state.lastFrame = performance.now(); + + if (state.planes.length === 0) { + const initialPlanes = + coarsePointer.matches ? 2 : 5; + + for (let index = 0; index < initialPlanes; index++) { + const plane = createPlane(); + plane.age = Math.random() * 2; + state.planes.push(plane); + } + } + + state.animationFrameId = + requestAnimationFrame(animate); + } + + function updatePointer(event) { + const bounds = hero.getBoundingClientRect(); + + state.pointer.x = clamp( + event.clientX - bounds.left, + 0, + bounds.width + ); + + state.pointer.y = clamp( + event.clientY - bounds.top, + 0, + bounds.height + ); + + state.pointer.active = true; + } + + hero.addEventListener("pointermove", updatePointer, { + passive: true + }); + + hero.addEventListener("pointerleave", () => { + state.pointer.active = false; + }); + + window.addEventListener("resize", resizeCanvas, { + passive: true + }); + + document.addEventListener("visibilitychange", () => { + state.lastFrame = performance.now(); + }); + + reducedMotion.addEventListener("change", start); + + const resizeObserver = new ResizeObserver(() => { + resizeCanvas(); + + if (reducedMotion.matches) { + drawStaticScene(); + } + }); + + resizeObserver.observe(hero); + + const intersectionObserver = new IntersectionObserver( + (entries) => { + state.visible = + entries[0]?.isIntersecting ?? true; + + state.lastFrame = performance.now(); + }, + { + threshold: 0.05 + } + ); + + intersectionObserver.observe(hero); + + start(); +})(); \ No newline at end of file diff --git a/website/src/index.njk b/website/src/index.njk index daa145b..cf50733 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -2,9 +2,18 @@ layout: base.njk title: KST4Contest – Contest-Optimized ON4KST Chat Client description: KST4Contest is a modern ON4KST chat client built for VHF, UHF and SHF contest operation. +heroFx: true ---
+ + + +

The contest-optimized ON4KST client

KST4Contest