2 Commits

Author SHA1 Message Date
Marc Froehlich 6d3351175f Add interactive aircraft scatter hero effect 2026-07-12 23:19:27 +02:00
Marc Froehlich 674c5a62e3 Extending work at #51 for not adding users on UM3 message 2026-07-12 22:43:19 +02:00
6 changed files with 979 additions and 33 deletions
@@ -1357,20 +1357,26 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
/** /**
* Applies a UM3-style user-info update to an already known member. If ON4KST * Applies a UM3-style user-info update only to an already active chat member.
* sends the update before the matching enter message, the member is added so *
* later CH messages can resolve sender/receiver immediately. * ON4KST may send UM3 messages for stations that are not logged into the
* current channel, for example when a user changes locator/profile data on the
* website. Such stations must not be added to the visible chat member list,
* because they cannot be addressed in the channel. Their full information will
* be delivered again with UA0/UA5/UA2 when they actually join.
*
* @return true if an active member was found and updated, false if the UM3
* information was intentionally ignored.
*/ */
public void updateOrAddActiveChatMemberInfo(ChatMember updatedMember) { public boolean updateActiveChatMemberInfoIfPresent(ChatMember updatedMember) {
String key = buildActiveChatMemberKey(updatedMember); String key = buildActiveChatMemberKey(updatedMember);
if (key == null) { if (key == null) {
return; return false;
} }
ChatMember activeMember = activeChatMembersByCallAndCategory.get(key); ChatMember activeMember = activeChatMembersByCallAndCategory.get(key);
if (activeMember == null) { if (activeMember == null) {
addOrUpdateActiveChatMember(updatedMember); return false;
return;
} }
activeMember.setName(updatedMember.getName()); activeMember.setName(updatedMember.getName());
@@ -1381,6 +1387,15 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
activeMember.setQrb(updatedMember.getQrb()); activeMember.setQrb(updatedMember.getQrb());
activeMember.setQTFdirection(updatedMember.getQTFdirection()); activeMember.setQTFdirection(updatedMember.getQTFdirection());
fireUserListUpdate("User info updated"); fireUserListUpdate("User info updated");
return true;
}
/**
* Backward-compatible wrapper for older call sites. Despite the historic name,
* this method no longer adds unknown UM3 users to the active member model.
*/
public boolean updateOrAddActiveChatMemberInfo(ChatMember updatedMember) {
return updateActiveChatMemberInfoIfPresent(updatedMember);
} }
/** /**
@@ -1241,26 +1241,43 @@ public class MessageBusManagementThread extends Thread {
*/ */
if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) { if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) {
ChatMember stateChangeMember = new ChatMember(); if (splittedMessageLine.length < 6) {
System.out.println("[MSGBUSMGT, warning:] Malformed UM3 message ignored: "
+ messageToProcess.getMessageText());
} else {
ChatMember stateChangeMember = new ChatMember();
stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1])); stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
stateChangeMember.setCallSign(splittedMessageLine[2]);
stateChangeMember.setName(splittedMessageLine[3]);
stateChangeMember.setQra(splittedMessageLine[4]);
stateChangeMember.setState(Integer.parseInt(splittedMessageLine[5]));
stateChangeMember.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
stateChangeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(
client.getChatPreferences().getStn_loginLocatorMainCat(),
stateChangeMember.getQra()));
stateChangeMember.setQTFdirection(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())
.getBearing(new Location(stateChangeMember.getQra())));
stateChangeMember.setCallSign(splittedMessageLine[2]); /*
stateChangeMember.setName(splittedMessageLine[3]); * UM3 is only an info/profile update. ON4KST also sends it for stations
stateChangeMember.setQra(splittedMessageLine[4]); * that are not logged into this channel. Such stations must not become
stateChangeMember.setState(Integer.parseInt(splittedMessageLine[5])); * visible chat members, otherwise the user could try to address an
stateChangeMember.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); * offline callsign and receive server-side errors.
stateChangeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(client.getChatPreferences().getStn_loginLocatorMainCat(), stateChangeMember.getQra())); *
stateChangeMember.setQTFdirection(new Location(client.getChatPreferences().getStn_loginLocatorMainCat()).getBearing(new Location(stateChangeMember.getQra()))); * Therefore UM3 updates existing active members only. Unknown UM3 calls
* are intentionally ignored; if the station joins later, UA0/UA5/UA2
this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an * will deliver the complete current user information again.
// upodate */
if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(stateChangeMember.getCallSign())) {
// System.out.println("[MSGBUSMGT:] DXCluster Message detected "); boolean updatedActiveMember = this.client.updateActiveChatMemberInfoIfPresent(stateChangeMember);
if (updatedActiveMember) {
// -1 could be the case if mycall is processed; own call is not displayed in the active-member list. this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an update
if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(stateChangeMember.getCallSign())) { } else {
this.client.updateOrAddActiveChatMemberInfo(stateChangeMember); System.out.println("[MSGBUSMGT, Info:] UM3 ignored for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
}
} }
} else } else
+5
View File
@@ -81,5 +81,10 @@
</div> </div>
</div> </div>
</footer> </footer>
{% if heroFx %}
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
{% endif %}
</body> </body>
</html> </html>
+198 -8
View File
@@ -7,10 +7,22 @@
--text: #f8fafc; --text: #f8fafc;
--muted: #aab6ca; --muted: #aab6ca;
--soft: #64748b; --soft: #64748b;
--accent: #38bdf8; /*--accent: #38bdf8;*/
--accent2: #a855f7; /*--accent2: #a855f7;*/
--accent3: #22c55e; /*--accent3: #22c55e;*/
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45); --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 { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient(circle at 14% 12%, rgba(168, 85, 247, 0.30), transparent 28%), radial-gradient(
radial-gradient(circle at 82% 16%, rgba(56, 189, 248, 0.24), transparent 30%), circle at 14% 12%,
radial-gradient(circle at 50% 85%, rgba(34, 197, 94, 0.11), transparent 35%), rgba(52, 165, 57, 0.20),
linear-gradient(180deg, #050816 0%, #07111f 100%); 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); 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; line-height: 1.65;
overflow-x: hidden; overflow-x: hidden;
} }
@@ -562,3 +600,155 @@ a:hover {
grid-template-columns: 1fr; 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;
}
}
+710
View File
@@ -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();
})();
+9
View File
@@ -2,9 +2,18 @@
layout: base.njk layout: base.njk
title: KST4Contest Contest-Optimized ON4KST Chat Client title: KST4Contest Contest-Optimized ON4KST Chat Client
description: KST4Contest is a modern ON4KST chat client built for VHF, UHF and SHF contest operation. description: KST4Contest is a modern ON4KST chat client built for VHF, UHF and SHF contest operation.
heroFx: true
--- ---
<section class="hero hero-split"> <section class="hero hero-split">
<canvas
class="hero-radio-fx"
data-hero-radio-fx
aria-hidden="true">
</canvas>
<div class="hero-fx-vignette" aria-hidden="true"></div>
<div> <div>
<p class="badge">The contest-optimized ON4KST client</p> <p class="badge">The contest-optimized ON4KST client</p>
<h1>KST4Contest</h1> <h1>KST4Contest</h1>