(function () { "use strict"; if (document.body.dataset.view !== "map") return; const ids = [ "mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle", "mapModel", "mapRepair", "mapStatus", "mapClearFilters", "mapProblemsOnly", "mapFilterCount", "mapScope", "mapSummary", "mapFreshness", "mapCriticalState", "mapCriticalTitle", "mapCriticalDetail", "mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice", "mapPermissionNote", "mapViewport", "mapLegacySection", "mapLegacyPositions", "mapUnplacedSection", "mapUnplaced", "mapInspector", "mapInspectorTitle", "mapInspectorStatus", "mapInspectorDetails", "mapInspectorClose", "mapOpenMiner" ]; const el = {}; for (const id of ids) el[id] = document.getElementById(id); const state = { data: null, siteId: "", roomId: "", layer: "status", query: "", lifecycle: "", model: "", repair: "", status: "", problemsOnly: false, minerId: "", minerDetailsIncluded: false }; function create(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function display(value, fallback) { if (value === null || value === undefined || String(value).trim() === "") { return fallback || "Unavailable"; } return String(value); } function shelfLabel(value) { const label = display(value, "Unlabelled shelf"); return /^shelf\b/i.test(label) ? label : "Shelf " + label; } function words(value) { return display(value, "Unknown").replaceAll("_", " ").toLowerCase(); } function dateTime(value) { if (!value) return "Unavailable"; const parsed = new Date(value); return Number.isNaN(parsed.valueOf()) ? "Unavailable" : parsed.toLocaleString(); } function metric(value, suffix) { return typeof value === "number" && Number.isFinite(value) ? value + suffix : "Unavailable"; } function age(value) { if (!value) return "Never observed"; const time = new Date(value).valueOf(); if (Number.isNaN(time)) return "Observation time unavailable"; const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000)); if (seconds < 60) return seconds + "s ago"; if (seconds < 3600) return Math.floor(seconds / 60) + "m ago"; if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago"; return Math.floor(seconds / 86400) + "d ago"; } const PROBLEM_KEYS = new Set([ "offline", "unknown", "unstable", "repair", "conflict", "blocked", "expected" ]); const MEASUREMENT_LAYERS = { temperature: { field: "chip_temp_max_c", suffix: " C max" }, hashrate: { field: "hashrate_ths", suffix: " TH/s" }, power: { field: "power_w", suffix: " W" } }; function operational(position) { const kind = words(position.position_kind); const status = words(position.position_status); if (position.conflict_reason || status === "conflict") { return { key: "conflict", label: "Location conflict", mark: "Conflict" }; } if (kind === "blocked" || status === "blocked") { return { key: "blocked", label: "Blocked position", mark: "Blocked" }; } if (kind === "switch" || status === "switch") { return { key: "switch", label: "Switch / equipment", mark: "Equipment" }; } if (!position.occupied) { if (position.expected_mac) { return { key: "expected", label: "Expected miner not resolved", mark: "Expected" }; } if (kind === "miner slot" && status === "available") { return { key: "empty", label: "Empty position", mark: "Empty" }; } return { key: "unknown", label: "Unknown position state", mark: "Unknown" }; } if (!state.minerDetailsIncluded) { return { key: "restricted", label: "Occupied; miner details restricted", mark: "Restricted" }; } if (position.active_repair || position.lifecycle_status === "in_repair") { return { key: "repair", label: "In repair", mark: "Repair" }; } if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") { return { key: "retired", label: "Retired / decommissioned", mark: "Retired" }; } if (position.lifecycle_status === "spare") { return { key: "spare", label: "Spare inventory", mark: "Spare" }; } const runtime = words(position.miner_status); if (runtime === "offline") { return { key: "offline", label: "Offline", mark: "Offline" }; } if (position.hash_stable === false) { return { key: "unstable", label: "Hash unstable", mark: "Unstable" }; } if (runtime === "online") { return { key: "online", label: "Online", mark: "Online" }; } return { key: "unknown", label: "Operational state unknown", mark: "Unknown" }; } function isProblem(position) { return PROBLEM_KEYS.has(operational(position).key); } function measurementScale(positions) { const config = MEASUREMENT_LAYERS[state.layer]; if (!config) return null; const values = positions.filter(function (position) { return position.occupied && matches(position) && (!state.problemsOnly || isProblem(position)); }).map(function (position) { return position[config.field]; }).filter(function (value) { return typeof value === "number" && Number.isFinite(value); }); if (!values.length) return { config: config, min: null, max: null }; let min = values[0]; let max = values[0]; for (const value of values.slice(1)) { min = Math.min(min, value); max = Math.max(max, value); } return { config: config, min: min, max: max }; } function relativePercent(value, scale) { if (!scale || scale.min === null || scale.max === null || typeof value !== "number" || !Number.isFinite(value)) return null; if (scale.min === scale.max) return 50; return Math.max(0, Math.min( 100, ((value - scale.min) / (scale.max - scale.min)) * 100 )); } function layerValue(position, condition) { if (!position.occupied) return condition.label; if (!state.minerDetailsIncluded) return condition.label; if (state.layer === "temperature") { return metric(position.chip_temp_max_c, " C max"); } if (state.layer === "hashrate") { return metric(position.hashrate_ths, " TH/s"); } if (state.layer === "power") { return metric(position.power_w, " W"); } if (state.layer === "repair") { if (position.active_repair) return words(position.active_repair.status); if (position.lifecycle_status === "in_repair") return "In-repair lifecycle"; if (position.asset_state === "repair") return "Legacy repair location"; return "No active repair"; } return condition.label; } function identity(position) { if (!state.minerDetailsIncluded) return "Miner details restricted"; return display(position.asset_id, display(position.current_mac, "Unidentified miner")); } function matches(position) { if (!position.occupied) return true; if (!state.minerDetailsIncluded) return true; const condition = operational(position); const haystack = [position.asset_id, position.miner_sn, position.current_mac, position.current_ip, position.model].map(function (value) { return display(value, "").toLowerCase(); }).join(" "); return (!state.query || haystack.includes(state.query.toLowerCase())) && (!state.lifecycle || position.lifecycle_status === state.lifecycle) && (!state.model || position.model === state.model) && (!state.status || condition.key === state.status) && (!state.repair || (state.repair === "active" && Boolean(position.active_repair)) || (state.repair === "none" && !position.active_repair) || (state.repair === "legacy" && position.asset_state === "repair" && !position.active_repair)); } function option(value, label) { const node = create("option", "", label); node.value = value; return node; } function unique(values) { return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true }); }); } function syncSelectors() { const sites = state.data.sites; if (!sites.some(function (site) { return String(site.id) === state.siteId; })) { state.siteId = sites.length ? String(sites[0].id) : ""; } el.mapSite.replaceChildren(); for (const site of sites) el.mapSite.appendChild(option(String(site.id), site.name)); el.mapSite.value = state.siteId; const rooms = state.data.rooms.filter(function (room) { return String(room.site_id) === state.siteId; }); if (!rooms.some(function (room) { return String(room.id) === state.roomId; })) { state.roomId = rooms.length ? String(rooms[0].id) : ""; } el.mapRoom.replaceChildren(); if (!rooms.length) el.mapRoom.appendChild(option("", "No rooms at this site")); for (const room of rooms) { el.mapRoom.appendChild(option(String(room.id), room.name)); } el.mapRoom.value = state.roomId; el.mapRoom.disabled = !rooms.length; const site = sites.find(function (item) { return String(item.id) === state.siteId; }); const room = rooms.find(function (item) { return String(item.id) === state.roomId; }); el.mapScope.textContent = site && room ? site.name + " / " + room.name : site ? site.name + " / No configured rooms" : "No configured sites"; } function syncPermissionControls() { const allowed = state.minerDetailsIncluded; if (!allowed) { state.layer = "status"; state.query = state.lifecycle = state.model = state.repair = state.status = ""; state.problemsOnly = false; el.mapSearch.value = ""; el.mapRepair.value = ""; el.mapProblemsOnly.checked = false; } for (const item of el.mapLayer.options) { item.disabled = !allowed && item.value !== "status"; } el.mapLayer.value = state.layer; el.mapSearch.disabled = !allowed; el.mapLifecycle.disabled = !allowed; el.mapModel.disabled = !allowed; el.mapRepair.disabled = !allowed; el.mapStatus.disabled = !allowed; el.mapClearFilters.disabled = !allowed; el.mapProblemsOnly.disabled = !allowed; el.mapSearch.closest(".field").hidden = !allowed; document.getElementById("mapFilters").hidden = !allowed; el.mapPermissionNote.hidden = allowed; } function syncFilterOptions() { const positions = state.data.positions.filter(function (position) { return String(position.room_id || "") === state.roomId && position.occupied; }); const lifecycle = unique(positions.map(function (item) { return item.lifecycle_status; })); const models = unique(positions.map(function (item) { return item.model; })); const statuses = unique(positions.map(function (item) { return operational(item).key; })); const selectedLifecycle = state.lifecycle; const selectedModel = state.model; const selectedStatus = state.status; el.mapLifecycle.replaceChildren(option("", "All lifecycle states")); lifecycle.forEach(function (value) { el.mapLifecycle.appendChild(option(value, words(value))); }); el.mapModel.replaceChildren(option("", "All models")); models.forEach(function (value) { el.mapModel.appendChild(option(value, value)); }); el.mapStatus.replaceChildren(option("", "All operational states")); statuses.forEach(function (value) { el.mapStatus.appendChild(option(value, words(value))); }); if (lifecycle.includes(selectedLifecycle)) el.mapLifecycle.value = selectedLifecycle; else state.lifecycle = ""; if (models.includes(selectedModel)) el.mapModel.value = selectedModel; else state.model = ""; if (statuses.includes(selectedStatus)) el.mapStatus.value = selectedStatus; else state.status = ""; } function stateUrl() { const params = new URLSearchParams(); const values = { site_id: state.siteId, room_id: state.roomId, layer: state.layer === "status" ? "" : state.layer, q: state.query, lifecycle: state.lifecycle, model: state.model, repair: state.repair, status: state.status, problems: state.problemsOnly ? "1" : "", miner_id: state.minerId }; for (const pair of Object.entries(values)) { if (pair[1]) params.set(pair[0], pair[1]); } return window.location.pathname + (params.size ? "?" + params.toString() : ""); } function setUrl(mode) { window.history[mode === "push" ? "pushState" : "replaceState"]( {}, "", stateUrl() ); } function readUrl() { const params = new URLSearchParams(window.location.search); state.siteId = params.get("site_id") || ""; state.roomId = params.get("room_id") || ""; state.layer = ["status", "temperature", "hashrate", "power", "repair"] .includes(params.get("layer")) ? params.get("layer") : "status"; state.query = (params.get("q") || "").slice(0, 160); state.lifecycle = params.get("lifecycle") || ""; state.model = params.get("model") || ""; state.repair = ["active", "none", "legacy"].includes(params.get("repair")) ? params.get("repair") : ""; state.status = params.get("status") || ""; state.problemsOnly = params.get("problems") === "1"; state.minerId = params.get("miner_id") || ""; syncPermissionControls(); el.mapLayer.value = state.layer; el.mapSearch.value = state.query; el.mapRepair.value = state.repair; el.mapProblemsOnly.checked = state.problemsOnly; } function appendLegendItem(key, label, mark) { const item = create("span", "legend-item"); const marker = create("span", "legend-marker", mark); marker.dataset.status = key; marker.setAttribute("aria-hidden", "true"); item.append(marker, document.createTextNode(label)); el.mapLegend.appendChild(item); } function legend(scale) { const entries = !state.minerDetailsIncluded ? [["restricted", "Miner details restricted", "Restricted"], ["empty", "Empty position", "Empty"], ["conflict", "Location conflict", "Conflict"], ["blocked", "Blocked position", "Blocked"], ["switch", "Switch or equipment", "Equipment"]] : state.layer === "status" ? [["online", "Online", "Online"], ["offline", "Offline", "Offline"], ["unstable", "Hash unstable", "Unstable"], ["repair", "In repair", "Repair"], ["spare", "Spare", "Spare"], ["retired", "Retired", "Retired"], ["unknown", "Operational state unknown", "Unknown"], ["empty", "Empty position", "Empty"], ["conflict", "Location conflict", "Conflict"]] : [["online", "Online marker", "Online"], ["offline", "Offline marker", "Offline"], ["unstable", "Hash unstable marker", "Unstable"], ["repair", "In-repair marker", "Repair"], ["unknown", "Unknown-state marker", "Unknown"], ["empty", "Empty position", "Empty"]]; el.mapLegend.replaceChildren(); el.mapLegend.appendChild(create( "strong", "legend-title", el.mapLayer.options[el.mapLayer.selectedIndex].text )); if (scale) { const range = create("span", "relative-scale"); if (scale.min === null || scale.max === null) { range.textContent = "Relative range unavailable: no valid displayed readings"; } else { const low = metric(scale.min, scale.config.suffix); const high = metric(scale.max, scale.config.suffix); range.setAttribute( "aria-label", "Relative range across emphasized miners from " + low + " to " + high ); range.append( create("span", "relative-scale-label", "Relative range (emphasized)"), create("span", "relative-scale-value", low), create("span", "relative-scale-track"), create("span", "relative-scale-value", high) ); } el.mapLegend.appendChild(range); } for (const entry of entries) { appendLegendItem(entry[0], entry[1], entry[2]); } } function positionTile(position, scale) { const condition = operational(position); const tile = create(position.occupied && position.miner_id ? "button" : "div", "map-position"); tile.dataset.status = condition.key; tile.dataset.positionId = position.location_position_id; tile.dataset.occupied = String(position.occupied); tile.dataset.problem = String(isProblem(position)); if (position.miner_id) tile.dataset.minerId = position.miner_id; if (position.occupied && !matches(position)) tile.dataset.filtered = "true"; if (state.problemsOnly && position.occupied && !isProblem(position)) { tile.dataset.problemsMuted = "true"; } if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true"; const scaleEligible = position.occupied && matches(position) && (!state.problemsOnly || isProblem(position)); const relative = scale && scaleEligible ? relativePercent(position[scale.config.field], scale) : null; if (relative !== null) { tile.dataset.measurement = "true"; tile.style.setProperty("--relative-percent", relative.toFixed(2) + "%"); } if (tile.tagName === "BUTTON") { tile.type = "button"; tile.tabIndex = -1; tile.setAttribute("aria-pressed", String(String(position.miner_id) === state.minerId)); tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) + ", " + identity(position) + ", " + condition.label + ", " + age(position.observed_at)); } else { tile.setAttribute("role", "group"); tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) + ", " + condition.label); } const head = create("span", "position-head"); const marker = create("span", "status-marker", condition.mark); marker.setAttribute("aria-hidden", "true"); head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)), marker); const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label); tile.append(head, name); if (position.occupied) { tile.append( create("span", "position-value", layerValue(position, condition)), create("span", "position-freshness", state.minerDetailsIncluded ? age(position.observed_at) : "Miner details restricted") ); } return tile; } function renderMap() { syncSelectors(); const positions = state.data.positions.filter(function (position) { return String(position.room_id || "") === state.roomId; }); if (state.minerId && !state.data.positions.some(function (position) { const visible = String(position.room_id || "") === state.roomId || !position.room_id; return visible && position.miner_id && String(position.miner_id) === state.minerId; })) state.minerId = ""; syncPermissionControls(); syncFilterOptions(); const scale = measurementScale(positions); legend(scale); el.mapViewport.replaceChildren(); if (!state.data.sites.length) { el.mapNotice.hidden = false; el.mapNotice.textContent = "No sites are configured. Use location management to create the physical hierarchy."; } else if (!state.roomId) { el.mapNotice.hidden = false; el.mapNotice.textContent = "This site has no configured rooms."; } else if (!positions.length) { el.mapNotice.hidden = false; el.mapNotice.textContent = "No positions are configured for this room."; } else { el.mapNotice.hidden = true; } const racks = new Map(); for (const position of positions) { const rack = display(position.rack_name, "Unlabelled rack"); const shelf = display(position.shelf_name, "Unlabelled shelf"); if (!racks.has(rack)) racks.set(rack, new Map()); if (!racks.get(rack).has(shelf)) racks.get(rack).set(shelf, []); racks.get(rack).get(shelf).push(position); } const fragment = document.createDocumentFragment(); for (const rackEntry of racks) { const rack = create("section", "map-rack"); rack.setAttribute("aria-label", "Rack " + rackEntry[0]); rack.appendChild(create("h2", "rack-title", "Rack " + rackEntry[0])); for (const shelfEntry of rackEntry[1]) { const shelf = create("section", "map-shelf"); shelf.setAttribute("aria-label", shelfLabel(shelfEntry[0])); shelf.appendChild(create("h3", "shelf-title", shelfLabel(shelfEntry[0]))); const grid = create("div", "position-grid"); for (const position of shelfEntry[1]) { grid.appendChild(positionTile(position, scale)); } shelf.appendChild(grid); rack.appendChild(shelf); } fragment.appendChild(rack); } el.mapViewport.appendChild(fragment); renderSummary(positions); renderLegacyPositions(); renderUnplaced(); renderInspector(); syncTileTabStops(); const count = [state.query, state.lifecycle, state.model, state.repair, state.status, state.problemsOnly].filter(Boolean).length; el.mapFilterCount.textContent = count ? "(" + count + " active)" : ""; el.mapFreshness.textContent = "Map generated " + dateTime(state.meta.generated_at) + (state.minerDetailsIncluded ? ". Telemetry age is shown per miner." : ". Miner details are restricted for this account."); } function renderSummary(positions) { const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0, repair: 0, unknown: 0, restricted: 0, problems: 0 }; for (const position of positions) { const condition = operational(position); if (position.occupied) facts.occupied += 1; if (condition.key in facts) facts[condition.key] += 1; if (isProblem(position)) facts.problems += 1; } el.mapSummary.replaceChildren(); const pairs = [["Positions", positions.length], ["Occupied", facts.occupied], ["Empty", facts.empty], ["Conflicts", facts.conflict]]; if (state.minerDetailsIncluded) pairs.splice(3, 0, ["Problems", facts.problems], ["Offline", facts.offline], ["In repair", facts.repair], ["Unknown", facts.unknown]); else pairs.splice(3, 0, ["Restricted", facts.restricted]); for (const pair of pairs) { const item = create("span", "summary-fact"); item.append(create("strong", "", String(pair[1])), document.createTextNode(pair[0])); el.mapSummary.appendChild(item); } } function renderLegacyPositions() { const positions = state.data.positions.filter(function (position) { return !position.room_id; }); el.mapLegacySection.hidden = !positions.length; el.mapLegacyPositions.replaceChildren(); for (const position of positions) { const item = create("article", "legacy-position-entry"); item.append(create("strong", "", display(position.position_label, "Position " + position.position_index)), create("span", "legacy-position-path", "Unscoped / Room label " + display(position.room_name, "Unavailable") + " / Rack " + display(position.rack_name, "Unavailable") + " / Shelf " + display(position.shelf_name, "Unavailable")), positionTile(position)); el.mapLegacyPositions.appendChild(item); } } function renderUnplaced() { const miners = state.data.virtual_miners.filter(function (miner) { return (!state.siteId || String(miner.site_id || "") === state.siteId) && (!state.roomId || !miner.room_id || String(miner.room_id) === state.roomId); }); el.mapUnplacedSection.hidden = !miners.length; el.mapUnplaced.replaceChildren(); for (const miner of miners) { const item = create("div", "unplaced-miner"); item.append(create("strong", "", display(miner.asset_id, display(miner.current_mac, "Unidentified miner"))), create("span", "", words(miner.asset_state) + " / " + words(miner.lifecycle_status)), create("span", "", miner.active_repair ? "Active repair: " + words(miner.active_repair.status) : "No active repair record")); el.mapUnplaced.appendChild(item); } } function detailRow(term, value) { el.mapInspectorDetails.append(create("dt", "", term), create("dd", "", display(value))); } function renderInspector() { const position = state.data.positions.find(function (item) { return item.miner_id && String(item.miner_id) === state.minerId; }); if (!position) { el.mapInspector.hidden = true; return; } const condition = operational(position); el.mapInspector.hidden = false; el.mapInspectorTitle.textContent = identity(position); el.mapInspectorStatus.textContent = condition.label + " / " + age(position.observed_at); el.mapInspectorStatus.dataset.status = condition.key; el.mapInspectorDetails.replaceChildren(); detailRow("Asset ID", position.asset_id); detailRow("Model", position.model || position.miner_type); detailRow("Serial", position.miner_sn); detailRow("Current MAC", position.current_mac); detailRow("Current IP", position.current_ip); const location = position.room_id ? [position.site_name, position.room_name, "Rack " + position.rack_name, shelfLabel(position.shelf_name), display(position.position_label, "Position " + position.position_index)].join(" / ") : ["Unscoped", "Room label " + display(position.room_name), "Rack " + display(position.rack_name), shelfLabel(position.shelf_name), display(position.position_label, "Position " + position.position_index)].join(" / "); detailRow("Location", location); detailRow("Lifecycle", words(position.lifecycle_status)); detailRow("Runtime", words(position.miner_status)); detailRow("Repair", position.active_repair ? words(position.active_repair.status) + " / " + words(position.active_repair.reason) : position.asset_state === "repair" ? "Legacy repair location state" : "No active repair"); detailRow("Hashrate", metric(position.hashrate_ths, " TH/s")); detailRow("Power", metric(position.power_w, " W")); detailRow("Chip temperature", metric(position.chip_temp_max_c, " C max")); detailRow("Observed", dateTime(position.observed_at)); const returnTo = stateUrl(); el.mapOpenMiner.href = "/miners/" + encodeURIComponent(position.miner_id) + "/detail?return_to=" + encodeURIComponent(returnTo); } function mapTiles() { return Array.from(el.mapWorkspace.querySelectorAll("button.map-position[data-miner-id]")); } function syncTileTabStops() { const tiles = mapTiles(); const current = tiles.find(function (tile) { return tile.dataset.minerId === state.minerId; }); for (const tile of tiles) tile.tabIndex = tile === current ? 0 : -1; if (!current && tiles.length) tiles[0].tabIndex = 0; } function syncSelection() { for (const tile of mapTiles()) { const selected = tile.dataset.minerId === state.minerId; tile.dataset.selected = String(selected); tile.setAttribute("aria-pressed", String(selected)); } renderInspector(); syncTileTabStops(); } function bind() { const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus]; let renderFrame = null; function scheduleRender() { if (renderFrame !== null) window.cancelAnimationFrame(renderFrame); renderFrame = window.requestAnimationFrame(function () { renderFrame = null; renderMap(); setUrl("replace"); }); } el.mapSite.addEventListener("change", function () { state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = ""; renderMap(); setUrl("push"); }); el.mapRoom.addEventListener("change", function () { state.roomId = el.mapRoom.value; state.minerId = ""; renderMap(); setUrl("push"); }); for (const control of controls) { control.addEventListener(control === el.mapSearch ? "input" : "change", function () { state.layer = el.mapLayer.value; state.query = el.mapSearch.value.trim(); state.lifecycle = el.mapLifecycle.value; state.model = el.mapModel.value; state.repair = el.mapRepair.value; state.status = el.mapStatus.value; scheduleRender(); }); } el.mapProblemsOnly.addEventListener("change", function () { state.problemsOnly = el.mapProblemsOnly.checked; scheduleRender(); }); el.mapClearFilters.addEventListener("click", function () { state.query = state.lifecycle = state.model = state.repair = state.status = ""; state.problemsOnly = false; el.mapSearch.value = ""; el.mapRepair.value = ""; el.mapProblemsOnly.checked = false; renderMap(); setUrl("replace"); }); el.mapWorkspace.addEventListener("click", function (event) { const tile = event.target.closest("button[data-miner-id]"); if (!tile || !el.mapWorkspace.contains(tile)) return; state.minerId = tile.dataset.minerId; syncSelection(); setUrl("push"); el.mapInspectorClose.focus(); }); el.mapWorkspace.addEventListener("keydown", function (event) { if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; const tile = event.target.closest("button.map-position[data-miner-id]"); if (!tile) return; const tiles = mapTiles(); const current = tiles.indexOf(tile); const delta = ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1; const next = tiles[(current + delta + tiles.length) % tiles.length]; event.preventDefault(); for (const item of tiles) item.tabIndex = item === next ? 0 : -1; next.focus(); }); el.mapInspectorClose.addEventListener("click", function () { const restoreId = state.minerId; state.minerId = ""; syncSelection(); setUrl("push"); const restore = mapTiles().find(function (tile) { return tile.dataset.minerId === restoreId; }); if (restore) { for (const tile of mapTiles()) tile.tabIndex = -1; restore.tabIndex = 0; restore.focus(); } }); el.mapRetry.addEventListener("click", load); window.addEventListener("popstate", function () { readUrl(); renderMap(); setUrl("replace"); }); } function validate(envelope) { return envelope && envelope.data && envelope.meta && Array.isArray(envelope.data.sites) && Array.isArray(envelope.data.rooms) && Array.isArray(envelope.data.positions) && Array.isArray(envelope.data.virtual_miners) && envelope.data.positions.every(function (item) { return item && typeof item.location_position_id === "string" && typeof item.position_index === "number"; }); } async function load() { el.mapLoading.hidden = false; el.mapCriticalState.hidden = true; el.mapWorkspace.hidden = true; const controller = new AbortController(); const timer = window.setTimeout(function () { controller.abort(); }, 15000); try { const response = await fetch("/api/location/map", { credentials: "same-origin", headers: { Accept: "application/json" }, signal: controller.signal }); if (response.status === 401) { window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search)); return; } if (response.status === 403) { el.mapCriticalTitle.textContent = "Map access unavailable"; el.mapCriticalDetail.textContent = "Your account cannot view location data."; el.mapCriticalState.hidden = false; return; } if (!response.ok) throw new Error("The map service returned " + response.status + "."); const envelope = await response.json(); if (!validate(envelope)) throw new Error("The map response was incomplete."); state.data = envelope.data; state.meta = envelope.meta; state.minerDetailsIncluded = envelope.meta.miner_details_included === true; readUrl(); syncSelectors(); el.mapWorkspace.hidden = false; renderMap(); setUrl("replace"); } catch (error) { el.mapCriticalTitle.textContent = "Map unavailable"; el.mapCriticalDetail.textContent = error && error.name === "AbortError" ? "MinerOps took too long to respond. Retry in a moment." : display(error && error.message, "The map could not be loaded."); el.mapCriticalState.hidden = false; } finally { window.clearTimeout(timer); el.mapLoading.hidden = true; } } bind(); load(); }());