diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index 2facea7..276747a 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -756,7 +756,7 @@ tbody tr:last-child td { /* Mine Map v1 */ .map-context { - padding: 15px; + padding: 10px 12px; border: 1px solid var(--line); background: var(--surface); } @@ -767,32 +767,76 @@ tbody tr:last-child td { display: flex; align-items: center; justify-content: space-between; - gap: 16px; + gap: 10px; } .map-context h2, .map-inspector-v1 h2, .unplaced-section h2 { margin: 0; - font-size: 17px; + font-size: 15px; } .map-control-grid { display: grid; - grid-template-columns: minmax(150px, .8fr) minmax(150px, .8fr) - minmax(170px, .9fr) minmax(220px, 1.4fr); - gap: 10px; - margin-top: 14px; + grid-template-columns: minmax(130px, .8fr) minmax(130px, .8fr) + minmax(150px, .9fr) minmax(190px, 1.35fr) auto; + align-items: end; + gap: 7px; + margin-top: 7px; +} + +.map-context .field { + gap: 3px; +} + +.map-context .field label { + font-size: 11px; +} + +.map-context select, +.map-context input[type="search"] { + min-height: 36px; + padding-top: 6px; + padding-bottom: 6px; +} + +.map-problems-control { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 36px; + padding: 6px 9px; + border: 1px solid #9aa6b1; + border-radius: 4px; + background: #f8fafb; + color: #263442; + font-size: 12px; + font-weight: 750; + cursor: pointer; + white-space: nowrap; +} + +.map-problems-control input { + width: 16px; + height: 16px; + margin: 0; + accent-color: #176b9a; +} + +.map-problems-control:has(input:checked) { + border-color: #176b9a; + background: #edf7fb; } .map-filters { - margin-top: 12px; + margin-top: 7px; border-top: 1px solid #e3e8ed; - padding-top: 10px; + padding-top: 5px; } .map-filters summary { - min-height: 36px; + min-height: 28px; cursor: pointer; color: #334252; font-size: 13px; @@ -803,7 +847,7 @@ tbody tr:last-child td { display: grid; grid-template-columns: repeat(4, minmax(135px, 1fr)) auto; align-items: end; - gap: 10px; + gap: 7px; } .map-filter-grid .quiet-button, @@ -815,7 +859,7 @@ tbody tr:last-child td { .map-status-band { align-items: flex-start; - margin: 12px 0; + margin: 7px 0; } .map-summary, @@ -843,7 +887,7 @@ tbody tr:last-child td { .mine-map-workspace { display: grid; - grid-template-columns: minmax(0, 1fr) 320px; + grid-template-columns: minmax(0, 1fr) 300px; border: 1px solid var(--line); background: var(--surface); } @@ -860,8 +904,8 @@ tbody tr:last-child td { } .map-legend { - min-height: 50px; - padding: 10px 14px; + min-height: 38px; + padding: 6px 9px; border-bottom: 1px solid var(--line); background: #f7f8fa; font-size: 12px; @@ -870,23 +914,72 @@ tbody tr:last-child td { .legend-item { display: inline-flex; align-items: center; - gap: 5px; + gap: 4px; } .legend-marker, .status-marker { display: inline-grid; place-items: center; - width: 24px; - height: 24px; border: 1px solid #758493; - border-radius: 4px; + border-radius: 3px; color: #263442; background: #eef1f4; - font-size: 10px; font-weight: 850; } +.legend-marker { + min-height: 21px; + padding: 2px 5px; + font-size: 9px; +} + +.status-marker { + flex: 0 1 auto; + min-height: 17px; + max-width: 100%; + padding: 1px 4px; + font-size: 8px; + line-height: 1.2; + text-align: center; + overflow-wrap: anywhere; +} + +.legend-marker[data-status="online"], +.map-position[data-status="online"] .status-marker { border-color: #658675; color: #315c45; background: #f0f6f2; } +.legend-marker[data-status="offline"], +.map-position[data-status="offline"] .status-marker { border-color: #b42318; color: #8f1d14; background: #fff; } +.legend-marker[data-status="unstable"], +.map-position[data-status="unstable"] .status-marker { border-color: #9a5b00; color: #754600; background: #fff; } +.legend-marker[data-status="repair"], +.map-position[data-status="repair"] .status-marker { border-color: #6b4ca5; color: #523884; background: #fff; } +.legend-marker[data-status="unknown"], +.map-position[data-status="unknown"] .status-marker { border-color: #596675; color: #354351; background: #fff; } + +.relative-scale { + display: inline-flex; + align-items: center; + gap: 5px; + padding-right: 8px; + border-right: 1px solid var(--line); + color: #40505f; +} + +.relative-scale-label { + font-weight: 750; +} + +.relative-scale-value { + font-variant-numeric: tabular-nums; +} + +.relative-scale-track { + width: 64px; + height: 6px; + border: 1px solid #8e9ba8; + background: linear-gradient(90deg, #dfe9ee, #176b9a); +} + .map-notice { margin: 14px; padding: 13px; @@ -898,11 +991,11 @@ tbody tr:last-child td { .map-viewport-v1 { display: grid; - gap: 14px; + gap: 8px; min-height: 380px; - max-height: calc(100vh - 330px); + max-height: calc(100vh - 265px); overflow: auto; - padding: 14px; + padding: 8px; background: #e9edf0; } @@ -914,7 +1007,7 @@ tbody tr:last-child td { .rack-title { margin: 0; - padding: 9px 12px; + padding: 6px 9px; color: #fff; background: #263442; font-size: 14px; @@ -922,7 +1015,7 @@ tbody tr:last-child td { .map-shelf { display: grid; - grid-template-columns: 112px minmax(0, 1fr); + grid-template-columns: 78px minmax(0, 1fr); border-top: 1px solid var(--line); } @@ -932,7 +1025,7 @@ tbody tr:last-child td { .shelf-title { margin: 0; - padding: 12px; + padding: 7px; color: #40505f; background: #f3f5f7; font-size: 12px; @@ -940,20 +1033,21 @@ tbody tr:last-child td { .position-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(142px, 1fr)); - gap: 7px; - padding: 8px; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: 4px; + padding: 5px; } .map-position { + position: relative; display: grid; grid-template-rows: auto auto auto auto; - gap: 5px; + gap: 2px; min-width: 0; - min-height: 118px; - padding: 9px; + min-height: 84px; + padding: 6px; border: 1px solid #8e9ba8; - border-left: 5px solid #758493; + border-left: 3px solid #758493; border-radius: 4px; color: var(--ink); background: #fff; @@ -974,17 +1068,43 @@ button.map-position:hover { } .map-position[data-filtered="true"] { - opacity: .34; + opacity: .28; filter: grayscale(1); } -.map-position[data-status="online"] { border-left-color: #177245; } +.map-position[data-problems-muted="true"] { + opacity: .16; + filter: grayscale(.8); +} + +.map-position[data-selected="true"] { + opacity: 1; + filter: none; +} + +.map-position[data-problem="true"] { + border-left-width: 5px; + font-weight: 650; +} + +.map-position[data-measurement="true"]::after { + position: absolute; + right: auto; + bottom: 0; + left: 0; + width: var(--relative-percent); + height: 4px; + background: #176b9a; + content: ""; +} + +.map-position[data-status="online"] { border-left-color: #658675; } .map-position[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; } .map-position[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; } .map-position[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; } .map-position[data-status="spare"] { border-left-color: #1677b8; background: #f1f8fc; } .map-position[data-status="retired"] { border-left-color: #4d5660; background: #eef0f2; } -.map-position[data-status="empty"] { border-style: dashed; border-left-color: #8e9ba8; background: #f8fafb; } +.map-position[data-status="empty"] { min-height: 56px; border-style: dashed; border-left-color: #aeb8c2; color: #687785; background: transparent; opacity: .62; } .map-position[data-status="expected"] { border-left-color: #996600; background: #fff9e8; } .map-position[data-status="conflict"] { border-left-color: #b42318; background: #fff0ee; } .map-position[data-status="blocked"] { border-left-color: #4d5660; background: #e5e8eb; } @@ -1005,10 +1125,10 @@ button.map-position:hover { overflow-wrap: anywhere; } -.position-label { font-size: 12px; } -.position-miner { font-size: 13px; font-weight: 750; } -.position-value { color: #334252; font-size: 12px; } -.position-freshness { color: var(--muted); font-size: 10px; } +.position-label { flex: 1 1 auto; min-width: 0; font-size: 10px; line-height: 1.2; } +.position-miner { font-size: 11px; font-weight: 750; line-height: 1.2; } +.position-value { color: #334252; font-size: 10px; font-variant-numeric: tabular-nums; } +.position-freshness { color: var(--muted); font-size: 9px; } .map-inspector-v1 { position: sticky; @@ -1110,14 +1230,19 @@ button.map-position:hover { .map-context-head, .map-status-band { align-items: flex-start; flex-direction: column; } .map-control-grid, - .map-filter-grid { grid-template-columns: 1fr; } + .map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .map-search { grid-column: 1 / -1; } + .map-problems-control { min-height: 44px; } .map-viewport-v1 { max-height: none; padding: 9px; } .map-shelf { grid-template-columns: 1fr; } .shelf-title { position: sticky; left: 0; z-index: 1; } - .position-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); overflow-x: auto; } + .position-grid { grid-template-columns: repeat(2, minmax(108px, 1fr)); overflow-x: auto; } .map-inspector-v1 { position: fixed; inset: 0; z-index: 80; max-height: none; overflow-y: auto; } } @media (max-width: 420px) { - .position-grid { grid-template-columns: minmax(150px, 1fr); } + .map-control-grid, + .map-filter-grid { grid-template-columns: 1fr; } + .map-search { grid-column: auto; } + .position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); } } diff --git a/backend/app/frontend/static/mine-map.js b/backend/app/frontend/static/mine-map.js index 95eca2d..227e8f8 100644 --- a/backend/app/frontend/static/mine-map.js +++ b/backend/app/frontend/static/mine-map.js @@ -6,6 +6,7 @@ const ids = [ "mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle", "mapModel", "mapRepair", "mapStatus", "mapClearFilters", + "mapProblemsOnly", "mapFilterCount", "mapScope", "mapSummary", "mapFreshness", "mapCriticalState", "mapCriticalTitle", "mapCriticalDetail", "mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice", @@ -27,6 +28,7 @@ model: "", repair: "", status: "", + problemsOnly: false, minerId: "", minerDetailsIncluded: false }; @@ -71,50 +73,100 @@ 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: "!" }; + return { key: "conflict", label: "Location conflict", mark: "Conflict" }; } if (kind === "blocked" || status === "blocked") { - return { key: "blocked", label: "Blocked position", mark: "x" }; + return { key: "blocked", label: "Blocked position", mark: "Blocked" }; } if (kind === "switch" || status === "switch") { - return { key: "switch", label: "Switch / equipment", mark: "S" }; + return { key: "switch", label: "Switch / equipment", mark: "Equipment" }; } if (!position.occupied) { if (position.expected_mac) { - return { key: "expected", label: "Expected miner not resolved", mark: "?" }; + return { key: "expected", label: "Expected miner not resolved", mark: "Expected" }; } if (kind === "miner slot" && status === "available") { - return { key: "empty", label: "Empty position", mark: "+" }; + return { key: "empty", label: "Empty position", mark: "Empty" }; } - return { key: "unknown", label: "Unknown position state", mark: "?" }; + return { key: "unknown", label: "Unknown position state", mark: "Unknown" }; } if (!state.minerDetailsIncluded) { - return { key: "restricted", label: "Occupied; miner details restricted", mark: "L" }; + 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: "R" }; + return { key: "repair", label: "In repair", mark: "Repair" }; } if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") { - return { key: "retired", label: "Retired / decommissioned", mark: "X" }; + return { key: "retired", label: "Retired / decommissioned", mark: "Retired" }; } if (position.lifecycle_status === "spare") { - return { key: "spare", label: "Spare inventory", mark: "P" }; + return { key: "spare", label: "Spare inventory", mark: "Spare" }; } const runtime = words(position.miner_status); if (runtime === "offline") { - return { key: "offline", label: "Offline", mark: "O" }; + return { key: "offline", label: "Offline", mark: "Offline" }; } if (position.hash_stable === false) { - return { key: "unstable", label: "Hash unstable", mark: "H" }; + return { key: "unstable", label: "Hash unstable", mark: "Unstable" }; } if (runtime === "online") { - return { key: "online", label: "Online", mark: "ON" }; + return { key: "online", label: "Online", mark: "Online" }; } - return { key: "unknown", label: "Operational state unknown", mark: "?" }; + 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) { @@ -208,8 +260,10 @@ 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"; @@ -221,6 +275,7 @@ 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; @@ -256,7 +311,8 @@ 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, miner_id: state.minerId + 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]); @@ -283,43 +339,95 @@ 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 legend() { + 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"], ["empty", "Empty"], - ["conflict", "Conflict"], ["blocked", "Blocked"], ["switch", "Equipment"]] + ? [["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"], ["offline", "Offline"], ["unstable", "Hash unstable"], - ["repair", "In repair"], ["spare", "Spare"], ["retired", "Retired"], - ["unknown", "Unknown"], ["restricted", "Miner details restricted"], - ["empty", "Empty"], ["conflict", "Conflict"]] - : [["reading", "Current numeric reading"], ["unknown", "Unavailable"], - ["repair", "Repair marker remains visible"], ["empty", "Empty"]]; + ? [["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", "", el.mapLayer.options[el.mapLayer.selectedIndex].text)); + 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) { - const item = create("span", "legend-item"); - const marker = create("span", "legend-marker", entry[0] === "online" ? "ON" : entry[0].slice(0, 1).toUpperCase()); - marker.dataset.status = entry[0]; - item.append(marker, document.createTextNode(entry[1])); - el.mapLegend.appendChild(item); + appendLegendItem(entry[0], entry[1], entry[2]); } } - function positionTile(position) { + 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; @@ -332,14 +440,19 @@ ", " + 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)), - create("span", "status-marker", condition.mark)); + marker); const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label); - const value = create("span", "position-value", layerValue(position, condition)); - const freshness = create("span", "position-freshness", position.occupied - ? state.minerDetailsIncluded ? age(position.observed_at) : "Miner details restricted" - : "Physical slot"); - tile.append(head, name, value, freshness); + 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; } @@ -354,7 +467,8 @@ })) state.minerId = ""; syncPermissionControls(); syncFilterOptions(); - legend(); + const scale = measurementScale(positions); + legend(scale); el.mapViewport.replaceChildren(); if (!state.data.sites.length) { el.mapNotice.hidden = false; @@ -386,7 +500,9 @@ shelf.setAttribute("aria-label", "Shelf " + shelfEntry[0]); shelf.appendChild(create("h3", "shelf-title", "Shelf " + shelfEntry[0])); const grid = create("div", "position-grid"); - for (const position of shelfEntry[1]) grid.appendChild(positionTile(position)); + for (const position of shelfEntry[1]) { + grid.appendChild(positionTile(position, scale)); + } shelf.appendChild(grid); rack.appendChild(shelf); } @@ -398,7 +514,8 @@ renderUnplaced(); renderInspector(); syncTileTabStops(); - const count = [state.query, state.lifecycle, state.model, state.repair, state.status].filter(Boolean).length; + 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." @@ -407,17 +524,19 @@ function renderSummary(positions) { const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0, - repair: 0, unknown: 0, restricted: 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, - ["Offline", facts.offline], ["In repair", facts.repair], ["Unknown", facts.unknown]); + ["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"); @@ -521,8 +640,27 @@ 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"); @@ -535,18 +673,25 @@ 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; - renderMap(); setUrl("replace"); + 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 = ""; - el.mapSearch.value = ""; el.mapRepair.value = ""; renderMap(); setUrl("replace"); + 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; - renderMap(); setUrl("push"); + syncSelection(); setUrl("push"); el.mapInspectorClose.focus(); }); el.mapWorkspace.addEventListener("keydown", function (event) { @@ -562,11 +707,16 @@ next.focus(); }); el.mapInspectorClose.addEventListener("click", function () { - const restoreId = state.minerId; state.minerId = ""; renderMap(); setUrl("push"); + const restoreId = state.minerId; state.minerId = ""; + syncSelection(); setUrl("push"); const restore = mapTiles().find(function (tile) { return tile.dataset.minerId === restoreId; }); - if (restore) { restore.tabIndex = 0; restore.focus(); } + if (restore) { + for (const tile of mapTiles()) tile.tabIndex = -1; + restore.tabIndex = 0; + restore.focus(); + } }); el.mapRetry.addEventListener("click", load); window.addEventListener("popstate", function () { diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index 2024473..b7ac33c 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -110,6 +110,10 @@ def _map_content(can_manage_locations: bool = False) -> str: