feat: add relay-backed web radar sharing

- publish radar state/bootstrap snapshots to an HTTP relay
- add shared waypoint sync through relay APIs and SSE updates
- add remote Caddy/deploy tooling and mock relay push script
- add static POIs, topo-tile availability checks, and tile-load throttling
- add WASM 3D map engine and Python map data-prep pipeline
- update worn clothing reads to include slot metadata
- add grid controls, render perf HUD, and marker/label scaling tweaks
- remove embedded map resource generation in favor of disk/relay maps
This commit is contained in:
67
2026-06-23 03:11:52 +08:00
parent 7f9a6620f9
commit 361c6baa8f
16 changed files with 539 additions and 147 deletions
+8 -55
View File
@@ -90,6 +90,7 @@ list(APPEND PROJECT_SOURCES
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/BoneInterpolator.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/OverlayWindow.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/Overlay/GameOverlay.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/Web/ServerPublisher.cpp"
)
list(REMOVE_DUPLICATES PROJECT_SOURCES)
@@ -100,60 +101,16 @@ set(VOLKDMA_SOURCES
)
# -------------------------------------------------------------------------
# Embed map PNGs as Windows RCDATA resources
#
# Maps present in maps/ at configure time are baked into the binary so the
# server works out-of-the-box without copying anything next to the exe.
# A PNG placed on disk at runtime (maps/<id>.png next to the exe) still
# takes priority, so maps can be updated without rebuilding.
#
# Re-run CMake if you add new PNGs to maps/ after the initial configure.
# EmbeddedMaps stub — maps are served from the relay server, not baked into the binary.
# MapTileService falls back to this when no disk PNG is found; returning
# {nullptr, 0} means it will always load from maps/ on disk (or return 404).
# -------------------------------------------------------------------------
set(MAPS_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/maps")
set(EMBEDDED_MAPS_RC "${CMAKE_CURRENT_BINARY_DIR}/maps_embedded.rc")
set(EMBEDDED_MAPS_CPP "${CMAKE_CURRENT_BINARY_DIR}/EmbeddedMaps.cpp")
set(MAP_IDS chernarusplus livonia namalsk banov deadfall deerisle esseker lux sakhal takistan alteria)
set(MAP_RIDS 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011)
# ── Base map RCDATA embedding ─────────────────────────────────────────────────
set(RC_LINES "")
set(CPP_CASES "")
foreach(MAP_ID MAP_RID IN ZIP_LISTS MAP_IDS MAP_RIDS)
if(EXISTS "${MAPS_SOURCE_DIR}/${MAP_ID}.png")
string(APPEND RC_LINES "${MAP_RID} RCDATA \"${MAPS_SOURCE_DIR}/${MAP_ID}.png\"\n")
string(APPEND CPP_CASES " if (mapId == \"${MAP_ID}\") return LoadRcData(${MAP_RID});\n")
message(STATUS "Embedding map: ${MAP_ID}")
else()
message(STATUS "Skipping map (not found): ${MAP_ID}")
endif()
endforeach()
file(WRITE "${EMBEDDED_MAPS_RC}" "// Auto-generated by CMake - do not edit.\n${RC_LINES}")
file(WRITE "${EMBEDDED_MAPS_CPP}"
"// Auto-generated by CMake - do not edit.
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <Windows.h>
#include <cstdint>
#include <utility>
"// Auto-generated by CMake — maps served from relay server, no embedded resources.
#include \"EmbeddedMaps.h\"
static std::pair<const uint8_t*, size_t> LoadRcData(int resourceId) {
HRSRC hRes = FindResource(nullptr, MAKEINTRESOURCE(resourceId), RT_RCDATA);
if (!hRes) return {nullptr, 0};
HGLOBAL hData = LoadResource(nullptr, hRes);
if (!hData) return {nullptr, 0};
const void* ptr = LockResource(hData);
DWORD sz = SizeofResource(nullptr, hRes);
if (!ptr || sz == 0) return {nullptr, 0};
return {static_cast<const uint8_t*>(ptr), sz};
}
std::pair<const uint8_t*, size_t> GetEmbeddedMap(const std::string& mapId) {
${CPP_CASES} return {nullptr, 0};
std::pair<const uint8_t*, size_t> GetEmbeddedMap(const std::string&) {
return {nullptr, 0};
}
")
@@ -164,11 +121,7 @@ add_executable(dayz-memory-cpp
${FRAMEWORK_SOURCES}
)
# Wire in the generated RC + CPP files.
target_sources(dayz-memory-cpp PRIVATE
"${EMBEDDED_MAPS_RC}"
"${EMBEDDED_MAPS_CPP}"
)
target_sources(dayz-memory-cpp PRIVATE "${EMBEDDED_MAPS_CPP}")
# -------------------------------------------------------------------------
# Include directories
+3
View File
@@ -19,3 +19,6 @@
- Minimap inset (fixed position, independent zoom level)
- Keyboard shortcuts: G=grid, T=topo, C=combat, P=POIs
- Route/waypoint drawing tool (manual overlay path)
## Bullet trails
- change bullet trails to be bright red boxes bright pure blue trails
+2
View File
@@ -69,6 +69,7 @@ OverlayConfig OverlayConfig::Load(const std::string& path) {
if (j.contains("webPort")) cfg.webPort = j["webPort"].get<int>();
if (j.contains("webPassword")) cfg.webPassword = j["webPassword"].get<std::string>();
spdlog::info("Config: loaded from {}", path);
} catch (const std::exception& ex) {
spdlog::warn("Config: parse error in {} — {} — using defaults", path, ex.what());
@@ -130,6 +131,7 @@ void OverlayConfig::Save(const std::string& path) const {
j["webPort"] = webPort;
j["webPassword"] = webPassword;
std::ofstream f(path);
f << j.dump(2);
spdlog::debug("Config: saved to {}", path);
+1
View File
@@ -63,6 +63,7 @@ struct OverlayConfig {
int webPort = 7777;
std::string webPassword = "";
// ------------------------------------------------------------------
// Load from path (returns default-constructed config on any error).
static OverlayConfig Load(const std::string& path);
+11 -1
View File
@@ -117,6 +117,16 @@ struct SkeletonBones {
bool valid = false;
};
// -------------------------------------------------------------------------
// Worn item — one entry from a player's WornClothes array
// -------------------------------------------------------------------------
struct WornItem {
std::string slot; // "headgear","vest","back","legs","feet","gloves","armband",
// "shoulder","melee","chest_holster", or "" if hash unknown
std::string item; // cleanName (or typeName fallback) of the worn entity
};
// -------------------------------------------------------------------------
// Player entry (full)
// -------------------------------------------------------------------------
@@ -130,7 +140,7 @@ struct DayZPlayerEntry {
bool isAdmin = false; // model matches a known invisible/admin model path
std::string nickname;
std::string itemInHands;
std::vector<std::string> wornItems;
std::vector<WornItem> wornItems;
std::string typeName;
std::string configName;
std::string modelName;
+52 -5
View File
@@ -166,11 +166,15 @@ namespace Offsets {
constexpr uint64_t ItemQuality = 0x194; // v1.29 [manual]
constexpr uint64_t Hands = 0x1B0; // v1.29
constexpr uint64_t HandItemValid = 0x1CC; // v1.29 [manual]
constexpr uint64_t WornClothes = 0x150; // v1.29 [manual] — ptr to weared-clothes grid object
constexpr uint64_t PlayerCargoGrid = 0x150; // alias kept for compatibility
constexpr uint64_t CargoGridCount = 0xC; // v1.29 [manual] — uint32 count at grid+0xC
constexpr uint64_t ItemPtr = 0x8; // v1.29 [manual] — ptr to items array at grid+0x8
constexpr uint64_t ItemSize = 0x10; // v1.29 [manual] — stride per item slot (16 bytes)
// WornClothes: *(inv+0x150) is a DIRECT pointer to the clothes data array.
// Each 16-byte entry: { uint32 slotHash, uint32 pad, uint64 entityPtr }
// Count: uint32 at inv+0x15C (embedded in inventory struct, NOT at clothesPtr+0xC).
// IDA: sub_140559180 = *(*(inv+336) + 16*i + 8); sub_14055A230 count = *(inv+348).
constexpr uint64_t WornClothes = 0x150; // v1.29 [IDA sub_140559180] — data ptr
constexpr uint64_t WornClothesCount = 0x15C; // v1.29 [IDA sub_14055A230] — uint32 count
constexpr uint64_t WornSlotStride = 16; // bytes per entry
constexpr uint64_t WornSlotHashOffset = 0; // uint32 slot-ID hash at entry+0
constexpr uint64_t WornSlotEntityOffset = 8; // uint64 entity ptr at entry+8
}
namespace SlowTable {
@@ -210,3 +214,46 @@ namespace Offsets {
}
} // namespace Offsets
// ---------------------------------------------------------------------------
// Slot hash helpers (Enfusion rolling hash, case-insensitive)
// ---------------------------------------------------------------------------
constexpr uint32_t ComputeSlotHash(const char* s) noexcept {
uint32_t h = 0;
while (*s) {
char c = *s++;
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + ('a' - 'A'));
h = static_cast<uint32_t>(c) + 37u * h;
}
return h;
}
namespace SlotHashes {
constexpr uint32_t Headgear = ComputeSlotHash("slotHeadgear");
constexpr uint32_t Vest = ComputeSlotHash("slotVest");
constexpr uint32_t Back = ComputeSlotHash("slotBack");
constexpr uint32_t Legs = ComputeSlotHash("slotLegs");
constexpr uint32_t Feet = ComputeSlotHash("slotFeet");
constexpr uint32_t Gloves = ComputeSlotHash("slotGloves");
constexpr uint32_t Armband = ComputeSlotHash("slotArmband");
constexpr uint32_t Shoulder = ComputeSlotHash("slotShoulder");
constexpr uint32_t Melee = ComputeSlotHash("slotMelee");
constexpr uint32_t ChestHolster = ComputeSlotHash("ChestHolster");
}
inline const char* SlotNameFromHash(uint32_t hash) noexcept {
switch (hash) {
case SlotHashes::Headgear: return "headgear";
case SlotHashes::Vest: return "vest";
case SlotHashes::Back: return "back";
case SlotHashes::Legs: return "legs";
case SlotHashes::Feet: return "feet";
case SlotHashes::Gloves: return "gloves";
case SlotHashes::Armband: return "armband";
case SlotHashes::Shoulder: return "shoulder";
case SlotHashes::Melee: return "melee";
case SlotHashes::ChestHolster: return "chest_holster";
default: return nullptr;
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ std::vector<DayZPlayerEntry> EntityCategoryProjector::BuildPlayers(
std::function<std::string(uint64_t)> heldItemResolver,
std::function<float(uint64_t)> healthResolver,
std::function<bool(uint64_t)> adminResolver,
std::function<std::vector<std::string>(uint64_t)> wornClothesResolver)
std::function<std::vector<WornItem>(uint64_t)> wornClothesResolver)
{
auto all = EnumerateEntities(near, far, slow);
+1 -1
View File
@@ -28,7 +28,7 @@ public:
std::function<std::string(uint64_t)> heldItemResolver,
std::function<float(uint64_t)> healthResolver,
std::function<bool(uint64_t)> adminResolver = nullptr,
std::function<std::vector<std::string>(uint64_t)> wornClothesResolver = nullptr);
std::function<std::vector<WornItem>(uint64_t)> wornClothesResolver = nullptr);
static std::vector<DayZAnimalEntry> BuildAnimals(
const std::vector<DayZNearEntityEntry>& near,
+43 -30
View File
@@ -568,17 +568,14 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
return false;
uint64_t clothesGrid = 0;
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
|| !MemoryValidation::IsValidUserAddress(clothesGrid))
// *(inv+0x150) = direct ptr to clothes data array; count at inv+0x15C.
uint64_t clothesData = 0;
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesData)
|| !MemoryValidation::IsValidUserAddress(clothesData))
return false;
uint64_t itemsArray = 0;
uint32_t count = 0;
if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
|| !MemoryValidation::IsValidUserAddress(itemsArray))
return false;
if (!m_memory.TryReadValue<uint32_t>(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count)
uint32_t count = 0;
if (!m_memory.TryReadValue<uint32_t>(pid, inventoryAddr + Offsets::Inventory::WornClothesCount, count)
|| count == 0 || count > 32)
return false;
@@ -599,9 +596,13 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
return false;
};
// Each entry is 16 bytes: { uint32 hash, uint32 pad, uint64 entityPtr }
for (uint32_t i = 0; i < count; ++i) {
uint64_t itemAddr = 0;
if (!m_memory.TryReadPointer(pid, itemsArray + i * sizeof(uint64_t), itemAddr)
if (!m_memory.TryReadPointer(pid,
clothesData + i * Offsets::Inventory::WornSlotStride
+ Offsets::Inventory::WornSlotEntityOffset,
itemAddr)
|| !MemoryValidation::IsValidUserAddress(itemAddr))
continue;
@@ -635,36 +636,42 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
};
// --- wornClothesResolver ---
// Chain: entity → +0x650 (inventory) → +0x150 (clothes grid ptr)
// grid → +0x8 (items array ptr), +0xC (uint32 count)
// items[i*8] → item entity → +0x180 (type) → cleanName
auto wornClothesResolver = [&](uint64_t addr) -> std::vector<std::string> {
std::vector<std::string> result;
// Chain: entity → inv (entity+0x650) → clothesData (*(inv+0x150), direct array ptr)
// count at inv+0x15C; each 16-byte entry: { uint32 slotHash, uint32 pad, uint64 entityPtr }
// IDA evidence: sub_140559180 = *(*(inv+336)+16*i+8); sub_14055A230 count=*(inv+348).
auto wornClothesResolver = [&](uint64_t addr) -> std::vector<WornItem> {
std::vector<WornItem> result;
uint64_t inventoryAddr = 0;
if (!m_memory.TryReadPointer(pid, addr + RuntimeOffsets::Inventory::Base, inventoryAddr)
|| !MemoryValidation::IsValidUserAddress(inventoryAddr))
return result;
uint64_t clothesGrid = 0;
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesGrid)
|| !MemoryValidation::IsValidUserAddress(clothesGrid))
return result;
uint64_t itemsArray = 0;
if (!m_memory.TryReadPointer(pid, clothesGrid + Offsets::Inventory::ItemPtr, itemsArray)
|| !MemoryValidation::IsValidUserAddress(itemsArray))
// *(inv+0x150) = direct pointer to clothes data array
uint64_t clothesData = 0;
if (!m_memory.TryReadPointer(pid, inventoryAddr + Offsets::Inventory::WornClothes, clothesData)
|| !MemoryValidation::IsValidUserAddress(clothesData))
return result;
// Count embedded in inventory struct at inv+0x15C
uint32_t count = 0;
if (!m_memory.TryReadValue<uint32_t>(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count)
if (!m_memory.TryReadValue<uint32_t>(pid, inventoryAddr + Offsets::Inventory::WornClothesCount, count)
|| count == 0 || count > 32)
return result;
result.reserve(count);
for (uint32_t i = 0; i < count; ++i) {
const uint64_t entryBase = clothesData
+ i * Offsets::Inventory::WornSlotStride;
// Read slot hash at entry+0 and entity ptr at entry+8 in one range.
uint32_t slotHash = 0;
m_memory.TryReadValue<uint32_t>(pid,
entryBase + Offsets::Inventory::WornSlotHashOffset, slotHash);
uint64_t itemAddr = 0;
if (!m_memory.TryReadPointer(pid, itemsArray + i * sizeof(uint64_t), itemAddr)
if (!m_memory.TryReadPointer(pid,
entryBase + Offsets::Inventory::WornSlotEntityOffset, itemAddr)
|| !MemoryValidation::IsValidUserAddress(itemAddr))
continue;
@@ -674,11 +681,17 @@ void DayZRuntimeService::RunLiveLoop(const RuntimeSession& session) {
continue;
EntityTypeMetadata meta = ReadEntityTypeMetadata(m_memory, pid, typeAddr);
std::string name;
if (!meta.cleanName.empty()) name = meta.cleanName;
else if (!meta.typeName.empty()) name = FormatEntityName(meta.typeName);
else if (!meta.configName.empty()) name = meta.configName;
if (!name.empty()) result.push_back(std::move(name));
std::string itemName;
if (!meta.cleanName.empty()) itemName = meta.cleanName;
else if (!meta.typeName.empty()) itemName = FormatEntityName(meta.typeName);
else if (!meta.configName.empty()) itemName = meta.configName;
if (itemName.empty()) continue;
const char* slotNamePtr = SlotNameFromHash(slotHash);
WornItem wi;
wi.slot = slotNamePtr ? slotNamePtr : "";
wi.item = std::move(itemName);
result.push_back(std::move(wi));
}
return result;
};
+43 -1
View File
@@ -27,6 +27,7 @@
WebRadarServer::WebRadarServer(WebRadarConfig config)
: m_config(std::move(config))
, m_relay(m_config.relay)
{}
WebRadarServer::~WebRadarServer() {
@@ -83,9 +84,12 @@ void WebRadarServer::Start() {
m_config.bindAddress, m_config.port);
}
});
m_relay.Start();
}
void WebRadarServer::Stop() {
m_relay.Stop();
m_stopping.store(true);
m_cv.notify_all();
if (m_server) m_server->stop();
@@ -103,11 +107,14 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
// trajectory rendering, and the update is cheap (no JSON involved).
m_bullets.Update(update.bullets, now);
bool mapChanged = false;
if (update.serverMapName.has_value()) {
const MapInfo* resolved = MapRegistry::Resolve(*update.serverMapName);
if (resolved) {
if (m_currentMap != resolved)
if (m_currentMap != resolved) {
spdlog::info("Web radar: map changed to '{}'", resolved->id);
mapChanged = true;
}
m_currentMap = resolved;
} else {
spdlog::warn("Web radar: unrecognised server map name '{}' — keeping current map",
@@ -121,9 +128,25 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
if (now < m_nextSnapshotMs) return;
m_nextSnapshotMs = now + m_config.snapshotIntervalMs;
auto buildT0 = std::chrono::steady_clock::now();
std::string jsonStr = m_snapshotSvc.BuildStateJson(
update, m_bullets.GetSnapshot(), m_currentMap, now);
auto buildUs = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - buildT0).count();
// Throttled perf log (~once / 5 s): payload build time + serialized size.
static int64_t s_nextPerfLogMs = 0;
if (now >= s_nextPerfLogMs) {
s_nextPerfLogMs = now + 5000;
size_t entities = update.players.size() + update.zombies.size()
+ update.animals.size() + update.carsAndBoats.size()
+ update.otherEntities.size() + update.items.size();
spdlog::info("[perf] state json: build={:.2f} ms size={:.1f} KB entities={}",
buildUs / 1000.0, jsonStr.size() / 1024.0, entities);
}
std::string relayState, relayBootstrap;
bool sendRelayBootstrap = false;
{
std::lock_guard<std::mutex> lk(m_cvMutex);
@@ -134,13 +157,21 @@ void WebRadarServer::PushSnapshot(const RuntimeUpdate& update) {
m_bootstrapJson = m_snapshotSvc.BuildBootstrapJson(
update, m_currentMap, m_config.port);
m_lastBootstrapMapId = std::move(mapId);
sendRelayBootstrap = true;
}
m_latestJson = std::move(jsonStr);
m_latestPayload = "event: state\ndata: " + m_latestJson + "\n\n";
++m_broadcastSeq;
relayState = m_latestJson;
if (sendRelayBootstrap) relayBootstrap = m_bootstrapJson;
}
m_cv.notify_all();
m_relay.PushState(std::move(relayState));
if (sendRelayBootstrap)
m_relay.PushBootstrap(std::move(relayBootstrap));
}
// -------------------------------------------------------------------------
@@ -297,7 +328,18 @@ void WebRadarServer::SetupRoutes() {
}
int code = 200;
auto tileT0 = std::chrono::steady_clock::now();
auto bytes = m_tiles.GetTile(*tileMap, tx, ty, code);
auto tileUs = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - tileT0).count();
// Throttled tile-serve latency log (~once / 5 s).
static int64_t s_nextTilePerfMs = 0;
int64_t tileNow = NowMs();
if (tileNow >= s_nextTilePerfMs) {
s_nextTilePerfMs = tileNow + 5000;
spdlog::info("[perf] tile serve: {:.2f} ms size={:.1f} KB code={}",
tileUs / 1000.0, bytes.size() / 1024.0, code);
}
res.status = code;
if (code == 200) {
// Cache tiles aggressively — they're static PNG slices that only
+3
View File
@@ -10,6 +10,7 @@
#include "Web/MapRegistry.h"
#include "Web/MapTileService.h"
#include "Web/WebSnapshotService.h"
#include "Web/ServerPublisher.h"
// Forward-declare httplib types to avoid pulling the whole header (which
// includes <Winsock2.h>) into every translation unit that includes this header.
@@ -24,6 +25,7 @@ struct WebRadarConfig {
int port = 7777;
std::string password = "";
int snapshotIntervalMs = 100; // max web snapshot rate (default 10 Hz)
ServerPublisher::Config relay;
};
// -------------------------------------------------------------------------
@@ -54,6 +56,7 @@ private:
MapTileService m_tiles;
MapTileService m_topoTiles{"maps/topo"};
const MapInfo* m_currentMap = nullptr;
ServerPublisher m_relay;
std::unique_ptr<httplib::Server> m_server;
std::thread m_thread;
+20 -2
View File
@@ -71,7 +71,16 @@ static json PlayerEntityJson(const DayZPlayerEntry& p, const MapInfo* map) {
obj["steamId"] = "";
obj["dead"] = p.isDead;
obj["handItem"] = p.itemInHands;
obj["wornItems"] = p.wornItems;
{
json arr = json::array();
for (const auto& w : p.wornItems) {
json e;
e["slot"] = w.slot;
e["item"] = w.item;
arr.push_back(std::move(e));
}
obj["wornItems"] = std::move(arr);
}
obj["visibleOnMap"] = true;
return obj;
}
@@ -84,7 +93,16 @@ static json PlayerListEntryJson(const DayZPlayerEntry& p) {
obj["visibleOnMap"] = true;
obj["dead"] = p.isDead;
obj["handItem"] = p.itemInHands;
obj["wornItems"] = p.wornItems;
{
json arr = json::array();
for (const auto& w : p.wornItems) {
json e;
e["slot"] = w.slot;
e["item"] = w.item;
arr.push_back(std::move(e));
}
obj["wornItems"] = std::move(arr);
}
obj["distance"] = -1;
return obj;
}
+7 -3
View File
@@ -47,9 +47,13 @@ int main() {
// Web radar server — settings come from config.
// -------------------------------------------------------------------------
WebRadarConfig webCfg;
webCfg.bindAddress = cfg.webBindAddress;
webCfg.port = cfg.webPort;
webCfg.password = cfg.webPassword;
webCfg.bindAddress = cfg.webBindAddress;
webCfg.port = cfg.webPort;
webCfg.password = cfg.webPassword;
webCfg.relay.enabled = true;
webCfg.relay.host = "139.99.193.190";
webCfg.relay.port = 9000;
webCfg.relay.secret = "changeme";
WebRadarServer webRadar(webCfg);
webRadar.Start();
+302 -35
View File
@@ -69,6 +69,8 @@ const defaultSettings = {
favoriteLootNames: [],
filterStyles: {},
filterExpanded: {},
gridLineWeight: 2.5,
gridLabelSize: 13,
// v2 UI state
sidebarState: "full",
activeTab: "tab-map",
@@ -139,8 +141,12 @@ const state = {
topoElements: new Map(), // key -> img, O(1) topo lookup
satElements: new Map(), // "z:tx:ty" -> img, O(1) satellite XYZ tile lookup
tilesRafPending: false, // throttle updateVisibleTiles to one RAF per frame
isZooming: false, // true while wheel is spinning; suppresses tile loads
zoomIdleTimer: null, // setTimeout handle for zoom-settle detection
cachedGridKey: null, // detects when grid needs rebuild
cachedGridNode: null, // cached SVG <g> for the grid
sharedWaypoints: [], // relay-backed waypoints visible to all clients
topoAvailable: true, // set false on first topo-tile 404; reset on map change
};
const elements = {
@@ -170,7 +176,6 @@ const elements = {
showLabels: document.getElementById("showLabels"),
showPOIs: document.getElementById("showPOIs"),
showGrid: document.getElementById("showGrid"),
showSatellite: document.getElementById("showSatellite"),
showSatmap: document.getElementById("showSatmap"),
showDistanceRings: document.getElementById("showDistanceRings"),
showMinimap: document.getElementById("showMinimap"),
@@ -194,6 +199,10 @@ const elements = {
labelSettingsReset: document.getElementById("labelSettingsReset"),
textSize: document.getElementById("textSize"),
textSizeValue: document.getElementById("textSizeValue"),
gridLineWeight: document.getElementById("gridLineWeight"),
gridLineWeightValue: document.getElementById("gridLineWeightValue"),
gridLabelSize: document.getElementById("gridLabelSize"),
gridLabelSizeValue: document.getElementById("gridLabelSizeValue"),
minimapEl: document.getElementById("minimapEl"),
minimapImg: document.getElementById("minimapImg"),
minimapCanvas: document.getElementById("minimapCanvas"),
@@ -204,12 +213,72 @@ const elements = {
zoomDisplay: document.getElementById("zoomDisplay"),
ctxMenu: document.getElementById("ctxMenu"),
ctxAddWaypoint: document.getElementById("ctxAddWaypoint"),
ctxAddSharedWaypoint: document.getElementById("ctxAddSharedWaypoint"),
ctxCopyCoords: document.getElementById("ctxCopyCoords"),
toastStack: document.getElementById("toastStack"),
toggle3d: document.getElementById("toggle3d"),
canvas3d: document.getElementById("canvas3d"),
};
// ── Perf instrumentation (enable with ?perf=1) ──────────────────────────────
// Zero-cost when disabled. Answers "is the render loop coupled to data arrival?"
// with numbers: it samples true display fps (a continuous rAF ticker) separately
// from how often render() actually runs and how often snapshots arrive. If
// renders/s ≈ data Hz while display fps stays ~60, rendering is data-coupled.
// Reports once/sec to an on-screen HUD.
const PERF = (() => {
const enabled = params.get("perf") === "1" || params.get("perf") === "true";
if (!enabled) {
const noop = () => {};
return { enabled: false, onSnapshot: noop, onRender: noop };
}
const acc = { snaps: 0, bytes: 0, parseMs: 0, applyMs: 0, renders: 0,
renderMs: 0, renderMax: 0, frames: 0, lastEntities: 0, lastBytes: 0 };
const hud = document.createElement("div");
hud.id = "perfHud";
hud.style.cssText = "position:fixed;top:48px;right:8px;z-index:99999;background:rgba(0,0,0,.82);" +
"color:#9effa0;font:11px/1.45 ui-monospace,Menlo,Consolas,monospace;padding:8px 10px;" +
"border:1px solid #333;border-radius:6px;white-space:pre;pointer-events:none;min-width:190px";
const attach = () => document.body && document.body.appendChild(hud);
if (document.body) attach(); else addEventListener("DOMContentLoaded", attach);
// Continuous rAF ticker — measures real display refresh, independent of data.
const tick = () => { acc.frames++; requestAnimationFrame(tick); };
requestAnimationFrame(tick);
let lastT = performance.now();
setInterval(() => {
const dt = (performance.now() - lastT) / 1000; lastT = performance.now();
const per = (n) => n / dt;
hud.textContent =
"PERF (?perf=1)\n" +
"data " + per(acc.snaps).toFixed(1) + " Hz\n" +
"renders " + per(acc.renders).toFixed(1) + " /s\n" +
"display " + per(acc.frames).toFixed(0) + " fps\n" +
"entities " + acc.lastEntities + "\n" +
"payload " + (acc.lastBytes / 1024).toFixed(1) + " KB\n" +
"parse " + (acc.snaps ? acc.parseMs / acc.snaps : 0).toFixed(2) + " ms\n" +
"apply " + (acc.snaps ? acc.applyMs / acc.snaps : 0).toFixed(2) + " ms\n" +
"render avg " + (acc.renders ? acc.renderMs / acc.renders : 0).toFixed(2) + " ms\n" +
"render max " + acc.renderMax.toFixed(2) + " ms";
acc.snaps = acc.bytes = acc.parseMs = acc.applyMs = acc.renders = acc.renderMs = acc.frames = 0;
acc.renderMax = 0;
}, 1000);
return {
enabled: true,
onSnapshot(bytes, parseMs, applyMs) {
acc.snaps++; acc.bytes += bytes; acc.parseMs += parseMs; acc.applyMs += applyMs;
acc.lastBytes = bytes;
const s = state.lastSnapshot;
acc.lastEntities = s ? ((s.players || []).length + (s.zombies || []).length +
(s.animals || []).length + (s.vehicles || []).length + (s.otherEntities || []).length +
(s.loot || []).length + (s.bullets || []).length) : 0;
},
onRender(ms) { acc.renders++; acc.renderMs += ms; if (ms > acc.renderMax) acc.renderMax = ms; },
};
})();
let filtersByKey = {};
const filterKeyByKind = {
players: "players",
@@ -449,12 +518,43 @@ function applyTransform() {
constrainViewState();
elements.canvas.style.transform = `translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.scale})`;
elements.canvas.style.setProperty("--inv-scale", 1 / state.scale);
elements.canvas.style.setProperty("--sqrt-inv-scale", 1 / Math.sqrt(state.scale));
elements.canvas.classList.toggle("grid-labels-visible", state.scale * MGRS_STEP >= 80);
scheduleVisibleTilesUpdate();
if (!state.isZooming) scheduleVisibleTilesUpdate();
updateZoomDisplay();
if (state.measureMode) drawMeasureLayer();
}
async function loadStaticPOIs(mapId, mapSize) {
if (!mapId || !mapSize || !state.bootstrap) return;
if ((state.bootstrap.pois || []).length > 0) return; // C++ already provided them
try {
const r = await fetch(`${serverOrigin}/pois/${encodeURIComponent(mapId)}.json`, { cache: "no-store" });
if (!r.ok || !state.bootstrap) return;
const data = await r.json();
if (!Array.isArray(data)) return;
state.bootstrap.pois = data.map((p) => ({
id: p.id || "",
label: p.label || "",
type: p.type || "",
x: Number(p.x) || 0,
y: mapSize - (Number(p.z) || 0),
}));
render();
} catch {}
}
async function checkTopoAvailable(mapId) {
if (!mapId) { state.topoAvailable = false; scheduleVisibleTilesUpdate(); return; }
try {
const r = await fetch(apiUrl(`/api/topo?mapId=${encodeURIComponent(mapId)}`), { cache: "no-store" });
state.topoAvailable = r.ok;
} catch {
state.topoAvailable = false;
}
scheduleVisibleTilesUpdate();
}
function applyMapMetadata(metadata, clearTiles = false) {
if (!metadata) return false;
const previous = state.bootstrap;
@@ -491,6 +591,9 @@ function applyMapMetadata(metadata, clearTiles = false) {
elements.markers.style.height = `${next.mapSize}px`;
if (clearTiles || changed) {
state.tileState = {};
state.topoAvailable = false;
checkTopoAvailable(next.mapId);
loadStaticPOIs(next.mapId, next.mapSize);
for (const img of state.tileElements.values()) img.remove();
state.tileElements.clear();
for (const img of state.topoElements.values()) img.remove();
@@ -801,10 +904,57 @@ function findNearestWaypoint(worldX, worldY, radius) {
return nearest;
}
function findNearestSharedWaypoint(worldX, worldY, radius) {
let nearest = null;
let nearestDist = radius;
for (const wp of (state.sharedWaypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
const dist = Math.hypot(wp.x - worldX, wp.y - worldY);
if (dist < nearestDist) { nearestDist = dist; nearest = wp; }
}
return nearest;
}
async function postSharedWaypoint(x, y, name) {
try {
const res = await fetch(apiUrl("/api/waypoints"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ x, y, name }),
});
if (!res.ok) showToast("Failed to save shared waypoint", "danger");
} catch {
showToast("Failed to save shared waypoint", "danger");
}
}
async function deleteSharedWaypoint(id) {
try {
const res = await fetch(apiUrl(`/api/waypoints/${id}`), { method: "DELETE" });
if (!res.ok) showToast("Failed to delete waypoint", "danger");
} catch {
showToast("Failed to delete waypoint", "danger");
}
}
function showContextMenu(x, y, worldX, worldY) {
state.contextMenuWorldPos = { x: worldX, y: worldY };
const old = document.getElementById("ctxRemoveWaypoint");
if (old) old.remove();
document.getElementById("ctxRemoveWaypoint")?.remove();
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
const nearShared = findNearestSharedWaypoint(worldX, worldY, 30 / state.scale);
if (nearShared) {
const removeBtn = document.createElement("button");
removeBtn.id = "ctxRemoveSharedWaypoint";
removeBtn.className = "ctx-item";
removeBtn.textContent = `Remove shared${nearShared.name ? `: ${nearShared.name}` : " waypoint"}`;
removeBtn.addEventListener("click", () => {
deleteSharedWaypoint(nearShared.id);
hideContextMenu();
});
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
}
const near = findNearestWaypoint(worldX, worldY, 30 / state.scale);
if (near) {
const removeBtn = document.createElement("button");
@@ -820,6 +970,7 @@ function showContextMenu(x, y, worldX, worldY) {
});
elements.ctxMenu.insertBefore(removeBtn, elements.ctxAddWaypoint);
}
elements.ctxMenu.style.left = `${x}px`;
elements.ctxMenu.style.top = `${y}px`;
elements.ctxMenu.classList.add("open");
@@ -827,8 +978,8 @@ function showContextMenu(x, y, worldX, worldY) {
function hideContextMenu() {
elements.ctxMenu.classList.remove("open");
const old = document.getElementById("ctxRemoveWaypoint");
if (old) old.remove();
document.getElementById("ctxRemoveWaypoint")?.remove();
document.getElementById("ctxRemoveSharedWaypoint")?.remove();
}
// ── Minimap ────────────────────────────────────────────────────────────────
@@ -893,6 +1044,13 @@ function updateMinimap() {
ctx.fillStyle = "#e2e8f0";
ctx.fill();
}
for (const wp of (state.sharedWaypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
ctx.beginPath();
ctx.arc(wp.x * mmScale, wp.y * mmScale, 2.5, 0, Math.PI * 2);
ctx.fillStyle = wp.color || "#f59e0b";
ctx.fill();
}
}
// ── Filter / visibility ────────────────────────────────────────────────────
@@ -1066,9 +1224,8 @@ function estimateLabelSize(text, fontSize) {
function getScaledLabelLayoutSize(kind, itemOrGroup, text, fontSize) {
const size = estimateLabelSize(text, fontSize);
if (!useFixedScreenLabel(kind, itemOrGroup)) return size;
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
return { width: size.width / effectiveScale, height: size.height / effectiveScale };
return { width: size.width / Math.sqrt(effectiveScale), height: size.height / Math.sqrt(effectiveScale) };
}
function buildPinnedLabelCandidates(entry, size) {
@@ -1412,7 +1569,7 @@ function computeLabelPlacements(entries) {
const groups = buildOrdinaryLootLabelGroups(entries);
for (const group of groups) {
const size = estimateLabelSize(group.labelText, group.label.textSize);
const size = getScaledLabelLayoutSize("loot", group, group.labelText, group.label.textSize);
const candidates = buildOrdinaryGroupLabelCandidates(group, size);
let bestPlacement = null;
let bestScore = Number.POSITIVE_INFINITY;
@@ -1846,13 +2003,18 @@ function createMarker(entry) {
glyph.style.background = markerColor;
glyph.style.borderWidth = `${borderSize}px`;
glyph.style.color = markerColor;
{
const _s = Math.max(minMapScale, Number(state.scale) || 1);
glyph.style.transform = `translate(-50%, -50%) scale(${1 / Math.sqrt(_s)})`;
}
if (kind === "players") {
const direction = document.createElement("div");
direction.className = "direction entity-direction";
direction.style.transform = `translate(-50%, -100%) rotate(${180 - (item.rotation || 0)}deg)`;
direction.style.color = markerColor;
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28))}px`;
const _dirScale = Math.max(minMapScale, Number(state.scale) || 1);
direction.style.height = `${Math.max(10, Number(filterStyle?.directionLength || 28)) / Math.sqrt(_dirScale)}px`;
marker.appendChild(direction);
}
@@ -1866,11 +2028,9 @@ function createMarker(entry) {
labelNode.style.color = markerColor;
labelNode.style.left = `${labelPlacement.left}px`;
labelNode.style.top = `${labelPlacement.top}px`;
if (useFixedScreenLabel(kind, item)) {
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / effectiveScale})`;
}
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
marker.appendChild(labelNode);
}
@@ -1893,10 +2053,10 @@ function createPriorityLabelGroup(group) {
labelNode.style.color = group.markerColor;
labelNode.style.left = `${labelPlacement.left}px`;
labelNode.style.top = `${labelPlacement.top}px`;
if (useFixedScreenLabel(group.kind, group)) {
{
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / effectiveScale})`;
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
}
marker.appendChild(labelNode);
@@ -1941,6 +2101,11 @@ function createLootLabelGroup(group) {
labelNode.style.color = group.markerColor;
labelNode.style.left = `${labelPlacement.left}px`;
labelNode.style.top = `${labelPlacement.top}px`;
{
const effectiveScale = Math.max(minMapScale, Number(state.scale) || 1);
labelNode.style.transformOrigin = "0 0";
labelNode.style.transform = `scale(${1 / Math.sqrt(effectiveScale)})`;
}
marker.appendChild(labelNode);
for (const entry of group.entries) {
@@ -2211,7 +2376,7 @@ function updateVisibleTiles() {
const keepMaxX = Math.min(tileCountX - 1, maxTileX + keepMargin);
const keepMaxY = Math.min(tileCountY - 1, maxTileY + keepMargin);
const satmapOn = state.settings.showSatmap;
const satmapOn = state.settings.showSatmap && state.topoAvailable;
elements.canvas.classList.toggle("satmap-active", satmapOn);
elements.tiles.style.display = satmapOn ? "none" : "";
elements.topoTiles.style.display = satmapOn ? "" : "none";
@@ -2285,7 +2450,7 @@ function updateVisibleTiles() {
}
// ── Satmap tiles (loaded when satmap is active) ─────────────────────────
if (satmapOn) {
if (satmapOn && state.topoAvailable) {
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
const key = `${tileX}:${tileY}`;
@@ -2297,7 +2462,11 @@ function updateVisibleTiles() {
img.style.cssText = `left:${tileX * tileSize}px;top:${tileY * tileSize}px;width:${tileSize + 1}px;height:${tileSize + 1}px`;
img.addEventListener("error", () => {
img.remove();
// keep key in state.topoElements as a "do not retry" sentinel
if (!state.topoAvailable) return;
state.topoAvailable = false;
for (const el of state.topoElements.values()) el.remove();
state.topoElements.clear();
scheduleVisibleTilesUpdate();
});
img.src = topoTileUrl(tileX, tileY);
state.topoElements.set(key, img);
@@ -2372,17 +2541,25 @@ function updateVisibleTiles() {
// ── Main render ────────────────────────────────────────────────────────────
function render() {
if (!state.lastSnapshot || !state.bootstrap) return;
if (!PERF.enabled) return renderImpl();
const t = performance.now();
renderImpl();
PERF.onRender(performance.now() - t);
}
if (state.settings.followPlayer && state.lastSnapshot.hasLocalPlayer && state.lastSnapshot.localPlayer) {
centerOn(state.lastSnapshot.localPlayer);
function renderImpl() {
if (!state.bootstrap) return;
const snap = state.lastSnapshot;
if (snap && state.settings.followPlayer && snap.hasLocalPlayer && snap.localPlayer) {
centerOn(snap.localPlayer);
}
const markerNodes = [];
const itemLabelNodes = [];
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", state.lastSnapshot?.bullets || []));
const pathNodes = buildBulletPathNodes(getVisibleCollection("bullets", snap?.bullets || []));
const localPlayer = state.lastSnapshot.hasLocalPlayer ? state.lastSnapshot.localPlayer : null;
const localPlayer = snap?.hasLocalPlayer ? snap.localPlayer : null;
if (localPlayer) {
const marker = document.createElement("div");
marker.className = "entity local-player-entity";
@@ -2466,6 +2643,30 @@ function render() {
markerNodes.push(wrapper);
}
// Shared waypoints (relay-backed, visible to all clients)
for (const wp of (state.sharedWaypoints || [])) {
if (typeof wp.x !== "number" || typeof wp.y !== "number") continue;
const wrapper = document.createElement("div");
wrapper.className = "entity waypoint-entity";
wrapper.style.left = `${wp.x}px`;
wrapper.style.top = `${wp.y}px`;
const inner = document.createElement("div");
inner.style.cssText = "position:absolute;left:0;top:0;transform:scale(var(--inv-scale));transform-origin:0 0";
const dot = document.createElement("div");
dot.className = "marker shared-waypoint";
if (wp.color) dot.style.borderColor = wp.color;
inner.appendChild(dot);
if (wp.name) {
const lbl = document.createElement("span");
lbl.className = "waypoint-label shared-waypoint-label";
if (wp.color) lbl.style.color = wp.color;
lbl.textContent = wp.name;
inner.appendChild(lbl);
}
wrapper.appendChild(inner);
markerNodes.push(wrapper);
}
const gridNodes = buildGridLayer();
const gridLabelNodes = buildGridLabels();
const combatNodes = buildCombatRings(localPlayer);
@@ -2712,11 +2913,7 @@ async function bootstrap() {
setTheme(state.settings.theme);
syncLabelSettingsUi();
loadMinimapImage();
// Enable/disable satellite checkbox based on whether tiles exist.
const hasSat = !!bootstrapData.satUrl;
elements.showSatellite.disabled = !hasSat;
elements.showSatellite.title = hasSat ? "" : "No satellite data — run: py -m dayzmap satellite --map <mapId>";
render(); // show waypoints/POIs even before first C++ state push
// Enable 3D button when processed data exists for the current map.
engine3d.manifestUrl = bootstrapData.manifestUrl || null;
@@ -2738,7 +2935,17 @@ function connectEvents() {
source.addEventListener("state", (event) => {
stopFallbackPolling();
applySnapshot(JSON.parse(event.data));
if (!PERF.enabled) { applySnapshot(JSON.parse(event.data)); return; }
const t0 = performance.now();
const snap = JSON.parse(event.data);
const t1 = performance.now();
applySnapshot(snap);
PERF.onSnapshot(event.data.length, t1 - t0, performance.now() - t1);
});
source.addEventListener("waypoints", (event) => {
state.sharedWaypoints = JSON.parse(event.data);
render();
});
source.onerror = () => {
@@ -2756,6 +2963,21 @@ function connectEvents() {
// ── UI sync helpers ────────────────────────────────────────────────────────
function syncGridSettings() {
const lw = Number(state.settings.gridLineWeight) || 2.5;
const ls = Number(state.settings.gridLabelSize) || 13;
elements.canvas.style.setProperty("--grid-line-weight", lw);
elements.canvas.style.setProperty("--grid-label-size", `${ls}px`);
if (elements.gridLineWeight) {
elements.gridLineWeight.value = String(lw);
elements.gridLineWeightValue.textContent = `${lw.toFixed(1)} px`;
}
if (elements.gridLabelSize) {
elements.gridLabelSize.value = String(ls);
elements.gridLabelSizeValue.textContent = `${ls} px`;
}
}
function syncLabelSettingsUi() {
elements.ordinaryLootSpread.value = String(state.settings.ordinaryLootSpread);
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
@@ -2785,7 +3007,6 @@ function syncAllCheckboxes() {
showLabels: elements.showLabels,
showPOIs: elements.showPOIs,
showGrid: elements.showGrid,
showSatellite: elements.showSatellite,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
@@ -2946,7 +3167,6 @@ function bindUi() {
showLabels: elements.showLabels,
showPOIs: elements.showPOIs,
showGrid: elements.showGrid,
showSatellite: elements.showSatellite,
showSatmap: elements.showSatmap,
showDistanceRings: elements.showDistanceRings,
showMinimap: elements.showMinimap,
@@ -2954,7 +3174,13 @@ function bindUi() {
el.addEventListener("change", () => {
state.settings[key] = el.checked;
persistSettings();
if (key === "showSatmap") updateVisibleTiles();
if (key === "showSatmap") {
if (el.checked && !state.topoAvailable && state.bootstrap?.mapId) {
checkTopoAvailable(state.bootstrap.mapId);
} else {
updateVisibleTiles();
}
}
else if (key === "showMinimap") updateMinimap();
else render();
});
@@ -2992,6 +3218,21 @@ function bindUi() {
// Label settings
syncLabelSettingsUi();
// Grid settings
syncGridSettings();
elements.gridLineWeight.addEventListener("input", () => {
state.settings.gridLineWeight = Number(elements.gridLineWeight.value);
elements.gridLineWeightValue.textContent = `${state.settings.gridLineWeight.toFixed(1)} px`;
elements.canvas.style.setProperty("--grid-line-weight", state.settings.gridLineWeight);
persistSettings();
});
elements.gridLabelSize.addEventListener("input", () => {
state.settings.gridLabelSize = Number(elements.gridLabelSize.value);
elements.gridLabelSizeValue.textContent = `${state.settings.gridLabelSize} px`;
elements.canvas.style.setProperty("--grid-label-size", `${state.settings.gridLabelSize}px`);
persistSettings();
});
elements.ordinaryLootSpread.addEventListener("input", () => {
state.settings.ordinaryLootSpread = Number(elements.ordinaryLootSpread.value);
elements.ordinaryLootSpreadValue.textContent = `${Number(state.settings.ordinaryLootSpread).toFixed(1)}x`;
@@ -3069,6 +3310,12 @@ function bindUi() {
state.lastX = event.clientX;
state.lastY = event.clientY;
state.pinchDistance = 0;
if (state.dragging && state.isZooming) {
state.isZooming = false;
clearTimeout(state.zoomIdleTimer);
state.zoomIdleTimer = null;
scheduleVisibleTilesUpdate();
}
return;
}
const pair = getActivePointerPair();
@@ -3134,10 +3381,16 @@ function bindUi() {
drawMeasureLayer(event.clientX, event.clientY);
});
// Wheel zoom
// Wheel zoom — suppress tile loading while spinning; load once zoom settles.
elements.viewport.addEventListener("wheel", (event) => {
if (event.target.closest("#ctxMenu")) return;
event.preventDefault();
state.isZooming = true;
clearTimeout(state.zoomIdleTimer);
state.zoomIdleTimer = setTimeout(() => {
state.isZooming = false;
scheduleVisibleTilesUpdate();
}, 150);
zoomAt(event.clientX, event.clientY, state.scale * (event.deltaY < 0 ? 1.1 : 0.9));
if (state.measureMode) drawMeasureLayer();
}, { passive: false });
@@ -3168,6 +3421,14 @@ function bindUi() {
render();
});
elements.ctxAddSharedWaypoint.addEventListener("click", () => {
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
const name = (window.prompt("Shared waypoint name (optional):") ?? "").trim();
const { x, y } = state.contextMenuWorldPos;
hideContextMenu();
postSharedWaypoint(x, y, name);
});
elements.ctxCopyCoords.addEventListener("click", () => {
if (!state.contextMenuWorldPos) { hideContextMenu(); return; }
const mapSize = state.bootstrap?.mapSize || 0;
@@ -3211,6 +3472,12 @@ ensureFilterSettings();
renderFilterCards();
bindUi();
// Fetch shared waypoints once on startup; SSE will handle live updates after connect.
fetch(apiUrl("/api/waypoints"), { cache: "no-store" })
.then((r) => r.ok ? r.json() : [])
.then((wps) => { if (Array.isArray(wps)) { state.sharedWaypoints = wps; render(); } })
.catch(() => {});
bootstrap()
.then(() => fetch(apiUrl("/api/state"), { cache: "no-store" }))
.then((response) => {
+19 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DayZ Web Radar</title>
<link rel="stylesheet" href="style.css?v=7">
<link rel="stylesheet" href="style.css?v=13">
</head>
<body>
@@ -53,8 +53,7 @@
<label class="toggle-row"><input type="checkbox" id="showLabels" checked><span>Labels</span></label>
<label class="toggle-row"><input type="checkbox" id="showPOIs" checked><span>Locations</span></label>
<label class="toggle-row"><input type="checkbox" id="showGrid"><span>Grid <kbd>G</kbd></span></label>
<label class="toggle-row"><input type="checkbox" id="showSatellite"><span>Satellite</span></label>
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Sat map</span></label>
<label class="toggle-row"><input type="checkbox" id="showSatmap"><span>Satellite map</span></label>
<label class="toggle-row"><input type="checkbox" id="showDistanceRings"><span>Distance rings</span></label>
<label class="toggle-row"><input type="checkbox" id="showMinimap"><span>Minimap</span></label>
</div>
@@ -152,6 +151,20 @@
</div>
</div>
<div class="tab-section">
<div class="section-label">Grid</div>
<label class="setting-row">
<span class="setting-name">Line weight</span>
<span id="gridLineWeightValue" class="setting-val">2.5 px</span>
<input type="range" id="gridLineWeight" min="0.5" max="6" step="0.5" value="2.5">
</label>
<label class="setting-row">
<span class="setting-name">Label size</span>
<span id="gridLabelSizeValue" class="setting-val">13 px</span>
<input type="range" id="gridLabelSize" min="8" max="24" step="1" value="13">
</label>
</div>
<div class="tab-section">
<div class="section-label">Theme</div>
<div class="theme-bar">
@@ -206,7 +219,8 @@
<!-- Right-click context menu -->
<div class="ctx-menu" id="ctxMenu">
<button class="ctx-item" id="ctxAddWaypoint">Add waypoint</button>
<button class="ctx-item" id="ctxAddWaypoint">Add waypoint (local)</button>
<button class="ctx-item ctx-item-shared" id="ctxAddSharedWaypoint">Add shared waypoint</button>
<button class="ctx-item" id="ctxCopyCoords">Copy coordinates</button>
</div>
@@ -228,6 +242,6 @@
<!-- ── Toast notifications ────────────────────────────────────────── -->
<div class="toast-stack" id="toastStack"></div>
<script src="app.js?v=7"></script>
<script src="app.js?v=13"></script>
</body>
</html>
+22 -7
View File
@@ -811,6 +811,18 @@ body.sidebar-hidden .viewport { left: 0; }
width: 10px; height: 10px;
}
.marker.shared-waypoint {
background: #f59e0b;
border: 2px solid #fde68a;
width: 10px; height: 10px;
border-radius: 2px;
transform: translate(-50%, -50%) rotate(45deg);
}
.shared-waypoint-label {
font-weight: 700;
}
.entity.bullet-phantom .marker.bullets {
opacity: 0.45;
box-shadow: 0 0 6px rgba(148,163,184,0.45);
@@ -957,11 +969,11 @@ body.sidebar-hidden .viewport { left: 0; }
/* ── Grid overlay ────────────────────────────────────────────────────── */
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: 1; }
.grid-minor { stroke: rgba(255,255,255,0.12); stroke-width: 0.5; }
.grid-major { stroke: rgba(255,255,255,0.30); stroke-width: calc(var(--grid-line-weight, 2.5) * var(--inv-scale, 1)); }
.grid-minor { stroke: rgba(255,255,255,0.12); stroke-width: calc(0.5 * var(--inv-scale, 1)); }
.canvas.satmap-active .grid-major { stroke: rgba(0,0,0,0.45); }
.canvas.satmap-active .grid-label-inner { font-size: 10px; color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
.canvas.satmap-active .grid-major { stroke: rgba(0,0,0,0.45); stroke-width: calc(var(--grid-line-weight, 2.5) * var(--inv-scale, 1)); }
.canvas.satmap-active .grid-label-inner { font-size: var(--grid-label-size, 13px); color: rgba(0,0,0,0.90); text-shadow: 0 1px 1px rgba(255,255,255,0.6); }
.grid-label-wrapper {
position: absolute;
@@ -972,12 +984,13 @@ body.sidebar-hidden .viewport { left: 0; }
.canvas.grid-labels-visible .grid-label-wrapper { visibility: visible; }
.grid-label-inner {
font-size: 10px;
font-size: var(--grid-label-size, 13px);
font-family: monospace;
color: rgba(255,255,255,0.50);
color: rgba(255,255,255,0.55);
white-space: nowrap;
line-height: 1;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
text-shadow: 0 1px 3px rgba(0,0,0,0.95);
will-change: transform;
}
/* ── Combat mode ─────────────────────────────────────────────────────── */
@@ -1058,6 +1071,8 @@ body.measure-active .viewport { cursor: crosshair; }
}
.ctx-item:hover { background: var(--bg-hover); }
.ctx-item-shared { color: #f59e0b; }
.ctx-item-shared:hover { color: #fde68a; }
/* ── Coord bar ───────────────────────────────────────────────────────── */