feat: polish Mine Map UX

This commit is contained in:
lussierj committed 2026-08-11 14:27:09 +00:00
1 parent d06ee7bcc6
commit 06ed8248f5
5 files changed
+383 -90

No files matched your search

+168 -43
View File
@@ -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)); }
}
+197 -47
View File
@@ -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 () {
+4
View File
@@ -110,6 +110,10 @@ def _map_content(can_manage_locations: bool = False) -> str:
<div class="field map-search"><label for="mapSearch">Find miner</label>
<input id="mapSearch" type="search" autocomplete="off"
placeholder="Asset ID, serial, MAC, or IP"></div>
<label class="map-problems-control" for="mapProblemsOnly">
<input id="mapProblemsOnly" type="checkbox">
<span>Problems only</span>
</label>
</div>
<details class="map-filters" id="mapFilters">
<summary>Filters <span id="mapFilterCount" aria-live="polite"></span></summary>
+1
View File
@@ -152,6 +152,7 @@ class FrontendShellTests(unittest.TestCase):
self.assertIn(label, response.text)
for value in (
'id="mapSite"', 'id="mapRoom"', 'id="mapLayer"',
'id="mapProblemsOnly"',
'id="mapViewport"', 'id="mapInspector"',
'/assets/minerops-mine-map.js',
):
+13
View File
@@ -329,6 +329,7 @@ class MineMapFrontendAuditTests(unittest.TestCase):
self.assertIn("syncTileTabStops", source)
self.assertIn("renderLegacyPositions", source)
self.assertIn("minerDetailsIncluded", source)
self.assertIn("mapProblemsOnly", source)
def test_map_has_factual_status_rules_without_thresholds_or_mutation(self):
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
@@ -342,6 +343,18 @@ class MineMapFrontendAuditTests(unittest.TestCase):
self.assertIn('if (!state.minerDetailsIncluded)', source)
self.assertIn('Number.isFinite(value)', source)
def test_problems_and_measurement_layers_use_factual_relative_values(self):
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn("const PROBLEM_KEYS = new Set", source)
for state in ("offline", "unknown", "unstable", "repair"):
self.assertIn(f'"{state}"', source)
self.assertIn("state.problemsOnly", source)
self.assertIn('"--relative-percent"', source)
self.assertIn("Relative range", source)
self.assertIn('data-problems-muted="true"', css)
self.assertIn(".relative-scale-track", css)
def test_map_repository_uses_constant_set_based_queries(self):
source = (Path(__file__).parents[1] / "app" / "locations" /
"repository.py").read_text(encoding="utf-8")