mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-07-13 16:16:44 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3351175f | |||
| 674c5a62e3 | |||
| 750d0a3be1 |
@@ -24,6 +24,7 @@ import kst4contest.logic.PriorityCalculator;
|
||||
import kst4contest.model.*;
|
||||
import kst4contest.test.MockKstServer;
|
||||
import kst4contest.utils.PlayAudioUtils;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.view.Kst4ContestApplication;
|
||||
|
||||
import java.io.*;
|
||||
@@ -34,6 +35,7 @@ import java.util.function.Predicate;
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
|
||||
@@ -318,33 +320,29 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
EnumSet<Band> offered = EnumSet.noneOf(Band.class);
|
||||
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember cm : getLst_chatMemberList()) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offered;
|
||||
}
|
||||
|
||||
@@ -356,21 +354,19 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
EnumSet<Band> worked = EnumSet.noneOf(Band.class);
|
||||
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember cm : getLst_chatMemberList()) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G); // optional, only if your Band enum supports it
|
||||
}
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G);
|
||||
}
|
||||
|
||||
return worked;
|
||||
}
|
||||
|
||||
@@ -645,7 +641,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
// rotatorClient.
|
||||
|
||||
this.lst_chatMemberList.clear();;
|
||||
// this.lst_chatMemberList.clear();;
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_clusterMemberList.clear();
|
||||
|
||||
this.setDisconnected(true);
|
||||
@@ -713,7 +710,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
}
|
||||
} else if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECTONLY)){
|
||||
|
||||
this.lst_chatMemberList.clear();;
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_clusterMemberList.clear();
|
||||
|
||||
|
||||
@@ -959,16 +956,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) {
|
||||
return member;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
|
||||
return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
|
||||
}
|
||||
|
||||
private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
@@ -976,22 +966,43 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) continue;
|
||||
|
||||
String locator = member.getQra();
|
||||
if (locator != null && !locator.isBlank()) {
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
|
||||
String locator = member.getQra();
|
||||
if (locator != null && !locator.isBlank()) {
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// private ChatMember resolveSkedTargetMember(String targetCallsignRaw) {
|
||||
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
|
||||
// return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
|
||||
//
|
||||
// }
|
||||
//
|
||||
// private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
//
|
||||
// for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
|
||||
// String locator = member.getQra();
|
||||
// if (locator != null && !locator.isBlank()) {
|
||||
// return locator.trim().toUpperCase(Locale.ROOT);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return null;
|
||||
// }
|
||||
|
||||
public StationMetricsService getStationMetricsService() {
|
||||
return stationMetricsService;
|
||||
}
|
||||
@@ -1014,10 +1025,14 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return new HashMap<>(lastInboundCategoryByCallSignRaw);
|
||||
}
|
||||
|
||||
// public List<ChatMember> snapshotChatMembers() {
|
||||
// synchronized (getLst_chatMemberList()) {
|
||||
// return new ArrayList<>(getLst_chatMemberList());
|
||||
// }
|
||||
// }
|
||||
|
||||
public List<ChatMember> snapshotChatMembers() {
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
return new ArrayList<>(getLst_chatMemberList());
|
||||
}
|
||||
return new ArrayList<>(activeChatMembersByCallAndCategory.values());
|
||||
}
|
||||
|
||||
public List<ContestSked> snapshotActiveSkeds() {
|
||||
@@ -1126,13 +1141,27 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(); // List of active stations
|
||||
|
||||
|
||||
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
|
||||
// of active stn in chat
|
||||
private FilteredList<ChatMember> lst_chatMemberListFiltered = new FilteredList<ChatMember>(chatMemberList);
|
||||
private SortedList<ChatMember> lst_chatMemberSortedFilteredList = new SortedList<ChatMember>(lst_chatMemberListFiltered);
|
||||
|
||||
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
|
||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
|
||||
|
||||
/*
|
||||
* Thread-safe active-member model.
|
||||
*
|
||||
* MessageBusManagementThread must not use the JavaFX TableView backing list as
|
||||
* its primary data model. Otherwise every add/remove fires FilteredList,
|
||||
* SortedList and TableView selection listeners on the MessageBus thread and JavaFX
|
||||
* can throw "Not on FX application thread" or internal listener NPEs.
|
||||
*
|
||||
* This map is the worker-thread safe source of truth. The ObservableList below
|
||||
* remains a UI mirror and is mutated only on the JavaFX application thread.
|
||||
*/
|
||||
private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/*
|
||||
* Message table update buffers.
|
||||
@@ -1161,6 +1190,254 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
// ******************************************************************************************************************************************
|
||||
|
||||
|
||||
/**
|
||||
* Executes UI-bound work on the JavaFX application thread.
|
||||
*
|
||||
* Worker threads may call this safely. If the caller is already on the FX
|
||||
* thread, the action is executed immediately to preserve ordering.
|
||||
*/
|
||||
private void runOnFxThread(Runnable action) {
|
||||
if (action == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
} else {
|
||||
Platform.runLater(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the active-member key. The raw/base callsign alone is not enough
|
||||
* because the same station can be logged into multiple ON4KST categories at
|
||||
* the same time. Therefore the category number is part of the key.
|
||||
*/
|
||||
private String buildActiveChatMemberKey(String callSign, ChatCategory category) {
|
||||
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSign);
|
||||
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int categoryNumber = category == null ? -1 : category.getCategoryNumber();
|
||||
return normalizedCallsign.trim().toUpperCase(Locale.ROOT) + "|" + categoryNumber;
|
||||
}
|
||||
|
||||
private String buildActiveChatMemberKey(ChatMember member) {
|
||||
if (member == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String callSign = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
|
||||
return buildActiveChatMemberKey(callSign, member.getChatCategory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or replaces an active chat member in the worker-thread model and mirrors
|
||||
* that change to the JavaFX list. This is the only supported path for ON4KST
|
||||
* user-enter events.
|
||||
*/
|
||||
public void addOrUpdateActiveChatMember(ChatMember member) {
|
||||
String key = buildActiveChatMemberKey(member);
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeChatMembersByCallAndCategory.put(key, member);
|
||||
|
||||
runOnFxThread(() -> {
|
||||
int existingIndex = findChatMemberIndexInUiListByKey(key);
|
||||
if (existingIndex >= 0) {
|
||||
lst_chatMemberList.set(existingIndex, member);
|
||||
} else {
|
||||
lst_chatMemberList.add(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an active chat member from the worker-thread model and from the UI
|
||||
* mirror. Removal from the ObservableList is always performed on the FX thread
|
||||
* because the list is bound to FilteredList/SortedList/TableView.
|
||||
*/
|
||||
public boolean removeActiveChatMember(ChatMember member) {
|
||||
String key = buildActiveChatMemberKey(member);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
|
||||
runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))));
|
||||
return removedMember != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active-member model and the JavaFX UI mirror. Use this instead of
|
||||
* getLst_chatMemberList().clear() from timers or worker threads.
|
||||
*/
|
||||
public void clearActiveChatMembers() {
|
||||
activeChatMembersByCallAndCategory.clear();
|
||||
runOnFxThread(() -> lst_chatMemberList.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a member from the thread-safe active model. This avoids reading the
|
||||
* TableView backing list from MessageBusManagementThread.
|
||||
*/
|
||||
public ChatMember findActiveChatMember(ChatMember lookForThis) {
|
||||
String key = buildActiveChatMemberKey(lookForThis);
|
||||
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
|
||||
}
|
||||
|
||||
public ChatMember findActiveChatMember(String callSign, ChatCategory category) {
|
||||
String key = buildActiveChatMemberKey(callSign, category);
|
||||
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
|
||||
}
|
||||
|
||||
public int getActiveChatMemberCount() {
|
||||
return activeChatMembersByCallAndCategory.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active category variants of a callsign. This is used when a QRG or
|
||||
* band hint should be propagated from one category instance to the same station
|
||||
* in another category.
|
||||
*/
|
||||
public List<ChatMember> findActiveChatMembersByRawCall(String callSignRaw) {
|
||||
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSignRaw);
|
||||
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String normalizedKeyPart = normalizedCallsign.trim().toUpperCase(Locale.ROOT);
|
||||
List<ChatMember> matchingMembers = new ArrayList<>();
|
||||
|
||||
for (ChatMember member : activeChatMembersByCallAndCategory.values()) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberCall = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
|
||||
String normalizedMemberCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
|
||||
if (normalizedMemberCall != null && normalizedMemberCall.equalsIgnoreCase(normalizedKeyPart)) {
|
||||
matchingMembers.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
return matchingMembers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates locator and derived direction values in the active model. The UI list
|
||||
* contains the same member object, but the user-list refresh is still triggered
|
||||
* on the JavaFX thread so TableView/map derived displays can repaint safely.
|
||||
*/
|
||||
public boolean updateActiveChatMemberLocator(ChatMember lookupMember, String newLocator) {
|
||||
ChatMember activeMember = findActiveChatMember(lookupMember);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setQra(newLocator);
|
||||
activeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(chatPreferences.getStn_loginLocatorMainCat(), newLocator));
|
||||
activeMember.setQTFdirection(new Location(chatPreferences.getStn_loginLocatorMainCat()).getBearing(new Location(newLocator)));
|
||||
fireUserListUpdate("Locator changed");
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean updateActiveChatMemberState(ChatMember lookupMember, int newState) {
|
||||
ChatMember activeMember = findActiveChatMember(lookupMember);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setState(newState);
|
||||
fireUserListUpdate("User state changed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a UM3-style user-info update only to an already active chat member.
|
||||
*
|
||||
* 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 boolean updateActiveChatMemberInfoIfPresent(ChatMember updatedMember) {
|
||||
String key = buildActiveChatMemberKey(updatedMember);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatMember activeMember = activeChatMembersByCallAndCategory.get(key);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setName(updatedMember.getName());
|
||||
activeMember.setQra(updatedMember.getQra());
|
||||
activeMember.setState(updatedMember.getState());
|
||||
activeMember.setLastActivity(updatedMember.getLastActivity());
|
||||
activeMember.setActivityTimeLastInEpoch(updatedMember.getActivityTimeLastInEpoch());
|
||||
activeMember.setQrb(updatedMember.getQrb());
|
||||
activeMember.setQTFdirection(updatedMember.getQTFdirection());
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a newly detected QRG/band on all active category variants of a station.
|
||||
* The old StringProperty is still set for compatibility with existing TableView
|
||||
* columns and DXCluster code.
|
||||
*/
|
||||
public void applyDetectedFrequencyToActiveMembers(ChatMember sender, Band detectedBand, double detectedFrequencyMhz) {
|
||||
if (sender == null || detectedBand == null || detectedFrequencyMhz <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
String displayFrequency = String.valueOf(detectedFrequencyMhz);
|
||||
String rawCall = sender.getCallSignRaw() != null ? sender.getCallSignRaw() : sender.getCallSign();
|
||||
|
||||
for (ChatMember member : findActiveChatMembersByRawCall(rawCall)) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
member.addKnownFrequency(detectedBand, detectedFrequencyMhz);
|
||||
member.setFrequency(new SimpleStringProperty(displayFrequency));
|
||||
}
|
||||
|
||||
// Also cover dummy senders that are not in the active model, e.g. [n/a] senders.
|
||||
sender.addKnownFrequency(detectedBand, detectedFrequencyMhz);
|
||||
sender.setFrequency(new SimpleStringProperty(displayFrequency));
|
||||
fireUserListUpdate("Frequency detected");
|
||||
}
|
||||
|
||||
private int findChatMemberIndexInUiListByKey(String key) {
|
||||
if (key == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < lst_chatMemberList.size(); i++) {
|
||||
if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2011,7 +2288,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
socket.close();
|
||||
|
||||
chatController.setConnectedAndLoggedIn(false);
|
||||
chatController.getLst_chatMemberList().clear();
|
||||
chatController.clearActiveChatMembers();
|
||||
|
||||
System.out.println("[Chatcontroller, Warning: ] Socket closed or disconnected");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,5 +81,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% if heroFx %}
|
||||
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<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>
|
||||
<p class="badge">The contest-optimized ON4KST client</p>
|
||||
<h1>KST4Contest</h1>
|
||||
|
||||
Reference in New Issue
Block a user