diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e65061..bfac284 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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/.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 -#include -#include +"// Auto-generated by CMake — maps served from relay server, no embedded resources. #include \"EmbeddedMaps.h\" - -static std::pair 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(ptr), sz}; -} - -std::pair GetEmbeddedMap(const std::string& mapId) { -${CPP_CASES} return {nullptr, 0}; +std::pair 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 diff --git a/TODO.md b/TODO.md index 5686770..f3139f1 100644 --- a/TODO.md +++ b/TODO.md @@ -18,4 +18,7 @@ - Player detail popover on marker click (worn items, health, distance) - Minimap inset (fixed position, independent zoom level) - Keyboard shortcuts: G=grid, T=topo, C=combat, P=POIs -- Route/waypoint drawing tool (manual overlay path) \ No newline at end of file +- Route/waypoint drawing tool (manual overlay path) + +## Bullet trails +- change bullet trails to be bright red boxes bright pure blue trails \ No newline at end of file diff --git a/src/Config.cpp b/src/Config.cpp index f016d8d..e7e9755 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -69,6 +69,7 @@ OverlayConfig OverlayConfig::Load(const std::string& path) { if (j.contains("webPort")) cfg.webPort = j["webPort"].get(); if (j.contains("webPassword")) cfg.webPassword = j["webPassword"].get(); + 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); diff --git a/src/Config.h b/src/Config.h index 4bb4a6e..6819a39 100644 --- a/src/Config.h +++ b/src/Config.h @@ -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); diff --git a/src/Core/Models.h b/src/Core/Models.h index 59a4e95..e28068a 100644 --- a/src/Core/Models.h +++ b/src/Core/Models.h @@ -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 wornItems; + std::vector wornItems; std::string typeName; std::string configName; std::string modelName; diff --git a/src/Offsets.h b/src/Offsets.h index 7637c43..3385cf3 100644 --- a/src/Offsets.h +++ b/src/Offsets.h @@ -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(c + ('a' - 'A')); + h = static_cast(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; + } +} diff --git a/src/Readers/EntityCategoryProjector.cpp b/src/Readers/EntityCategoryProjector.cpp index 3159403..45e084f 100644 --- a/src/Readers/EntityCategoryProjector.cpp +++ b/src/Readers/EntityCategoryProjector.cpp @@ -130,7 +130,7 @@ std::vector EntityCategoryProjector::BuildPlayers( std::function heldItemResolver, std::function healthResolver, std::function adminResolver, - std::function(uint64_t)> wornClothesResolver) + std::function(uint64_t)> wornClothesResolver) { auto all = EnumerateEntities(near, far, slow); diff --git a/src/Readers/EntityCategoryProjector.h b/src/Readers/EntityCategoryProjector.h index fd4132d..591f6d5 100644 --- a/src/Readers/EntityCategoryProjector.h +++ b/src/Readers/EntityCategoryProjector.h @@ -28,7 +28,7 @@ public: std::function heldItemResolver, std::function healthResolver, std::function adminResolver = nullptr, - std::function(uint64_t)> wornClothesResolver = nullptr); + std::function(uint64_t)> wornClothesResolver = nullptr); static std::vector BuildAnimals( const std::vector& near, diff --git a/src/Runtime/DayZRuntimeService.cpp b/src/Runtime/DayZRuntimeService.cpp index ba9edfb..2cfb3ff 100644 --- a/src/Runtime/DayZRuntimeService.cpp +++ b/src/Runtime/DayZRuntimeService.cpp @@ -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(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count) + uint32_t count = 0; + if (!m_memory.TryReadValue(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::vector 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 { + std::vector 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(pid, clothesGrid + Offsets::Inventory::CargoGridCount, count) + if (!m_memory.TryReadValue(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(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; }; diff --git a/src/Web/WebRadarServer.cpp b/src/Web/WebRadarServer.cpp index 6a58443..a332626 100644 --- a/src/Web/WebRadarServer.cpp +++ b/src/Web/WebRadarServer.cpp @@ -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::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 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::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 diff --git a/src/Web/WebRadarServer.h b/src/Web/WebRadarServer.h index 0f54f26..81b4af5 100644 --- a/src/Web/WebRadarServer.h +++ b/src/Web/WebRadarServer.h @@ -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 ) 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 m_server; std::thread m_thread; diff --git a/src/Web/WebSnapshotService.cpp b/src/Web/WebSnapshotService.cpp index ee46d77..e4338ba 100644 --- a/src/Web/WebSnapshotService.cpp +++ b/src/Web/WebSnapshotService.cpp @@ -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; } diff --git a/src/main.cpp b/src/main.cpp index 658e611..1e06243 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(); diff --git a/webroot/app.js b/webroot/app.js index 0cf60ba..93a6cac 100644 --- a/webroot/app.js +++ b/webroot/app.js @@ -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 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 "; + 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) => { diff --git a/webroot/index.html b/webroot/index.html index bc04fcd..9534dfa 100644 --- a/webroot/index.html +++ b/webroot/index.html @@ -4,7 +4,7 @@ DayZ Web Radar - + @@ -53,8 +53,7 @@ - - + @@ -152,6 +151,20 @@ +
+ + + +
+
@@ -206,7 +219,8 @@
- + +
@@ -228,6 +242,6 @@
- + diff --git a/webroot/style.css b/webroot/style.css index 942999f..072cfd0 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -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 ───────────────────────────────────────────────────────── */