Files
StarPilot/starpilot/system/the_galaxy/assets/components/tools/maps.js
T
2026-06-19 00:39:48 -05:00

521 lines
17 KiB
JavaScript

import { html, reactive } from "/assets/vendor/arrow-core.js";
const REQUEST_TIMEOUT_MS = 15000;
const ACTIVE_POLL_INTERVAL_MS = 1000;
const IDLE_POLL_INTERVAL_MS = 4000;
const state = reactive({
actionBusy: false,
catalogLoaded: false,
catalogSections: [],
error: "",
fetched: false,
loadingCatalog: true,
loadingStatus: true,
savingSchedule: false,
savingSelection: false,
scheduleDraft: "2",
scheduleSaved: "2",
scheduleOptions: [],
search: "",
selectedDraft: [],
selectedSaved: [],
status: {
cancelling: false,
downloading: false,
hasSelection: false,
isOnroad: false,
lastUpdate: "Never",
mapsPresent: false,
scheduleLabel: "Monthly",
selectedCount: 0,
storageBytes: 0,
},
tokenLabels: {},
});
let pollingHandle = null;
let statusInFlight = false;
function notify(message, variant = "success") {
if (typeof showSnackbar === "function") {
showSnackbar(message, variant);
} else if (variant === "error") {
console.error(message);
} else {
console.log(message);
}
}
function isMapsRouteActive() {
return window.location.pathname === "/manage_maps";
}
function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => {
const timerId = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
promise.then((value) => {
clearTimeout(timerId);
resolve(value);
}).catch((error) => {
clearTimeout(timerId);
reject(error);
});
});
}
async function fetchJson(url, options = {}) {
const response = await withTimeout(fetch(url, options), REQUEST_TIMEOUT_MS, `${url} request`);
const payload = await withTimeout(response.json(), REQUEST_TIMEOUT_MS, `${url} JSON parse`);
if (!response.ok) {
throw new Error(payload?.error || payload?.message || `Request failed (${response.status})`);
}
return payload;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value <= 0) return "0 MB";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const scaled = value / (1024 ** index);
return `${scaled >= 10 || index === 0 ? scaled.toFixed(0) : scaled.toFixed(2)} ${units[index]}`;
}
function uniqueSorted(values) {
return [...new Set(values)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
function selectionDirty() {
return !arraysEqual(state.selectedDraft, state.selectedSaved);
}
function scheduleDirty() {
return state.scheduleDraft !== state.scheduleSaved;
}
function getTokenLabel(token) {
return state.tokenLabels[token] || token;
}
function setDraftSelection(nextSelection) {
state.selectedDraft = uniqueSorted(nextSelection);
}
function toggleToken(token, enabled) {
const next = new Set(state.selectedDraft);
if (enabled) {
next.add(token);
} else {
next.delete(token);
}
setDraftSelection([...next]);
}
function setGroup(group, enabled) {
const next = new Set(state.selectedDraft);
for (const region of group.regions || []) {
if (enabled) {
next.add(region.token);
} else {
next.delete(region.token);
}
}
setDraftSelection([...next]);
}
function applyStatus(payload) {
const hadSelectionChanges = selectionDirty();
const hadScheduleChanges = scheduleDirty();
const selectedLocations = Array.isArray(payload.selectedLocations) ? uniqueSorted(payload.selectedLocations) : [];
state.status = {
cancelling: Boolean(payload.cancelling),
downloading: Boolean(payload.downloading),
hasSelection: Boolean(payload.hasSelection),
isOnroad: Boolean(payload.isOnroad),
lastUpdate: payload.lastUpdate || "Never",
mapsPresent: Boolean(payload.mapsPresent),
scheduleLabel: payload.scheduleLabel || "Monthly",
selectedCount: Number(payload.selectedCount || 0),
storageBytes: Number(payload.storageBytes || 0),
};
state.selectedSaved = selectedLocations;
if (!hadSelectionChanges) {
state.selectedDraft = selectedLocations;
}
const scheduleValue = String(payload.scheduleValue ?? "2");
state.scheduleSaved = scheduleValue;
if (!hadScheduleChanges) {
state.scheduleDraft = scheduleValue;
}
}
async function fetchCatalog() {
const payload = await fetchJson("/api/maps/catalog");
state.catalogSections = Array.isArray(payload.sections) ? payload.sections : [];
state.scheduleOptions = Array.isArray(payload.scheduleOptions) ? payload.scheduleOptions : [];
const tokenLabels = {};
for (const section of state.catalogSections) {
for (const group of section.groups || []) {
for (const region of group.regions || []) {
tokenLabels[region.token] = region.label;
}
}
}
state.tokenLabels = tokenLabels;
state.catalogLoaded = true;
state.loadingCatalog = false;
}
async function fetchStatus() {
if (statusInFlight) return;
statusInFlight = true;
try {
const payload = await fetchJson("/api/maps/status");
applyStatus(payload);
state.error = "";
} catch (error) {
state.error = error?.message || String(error);
} finally {
state.loadingStatus = false;
statusInFlight = false;
}
}
async function initializeMaps() {
state.loadingCatalog = true;
state.loadingStatus = true;
try {
await fetchCatalog();
await fetchStatus();
} catch (error) {
state.error = error?.message || String(error);
state.loadingCatalog = false;
state.loadingStatus = false;
}
}
function ensurePolling() {
if (pollingHandle) return;
const poll = async () => {
if (!isMapsRouteActive()) {
pollingHandle = null;
return;
}
try {
await fetchStatus();
} finally {
const nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
pollingHandle = setTimeout(poll, nextDelay);
}
};
pollingHandle = setTimeout(poll, ACTIVE_POLL_INTERVAL_MS);
}
async function saveSelection() {
if (state.savingSelection || !selectionDirty()) return;
state.savingSelection = true;
try {
const payload = await fetchJson("/api/maps/selection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selectedLocations: state.selectedDraft }),
});
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map selection saved.");
} catch (error) {
notify(error?.message || "Failed to save map selection.", "error");
} finally {
state.savingSelection = false;
}
}
async function saveSchedule() {
if (state.savingSchedule || !scheduleDirty()) return;
state.savingSchedule = true;
try {
const payload = await fetchJson("/api/maps/schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ schedule: state.scheduleDraft }),
});
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map schedule updated.");
} catch (error) {
notify(error?.message || "Failed to update map schedule.", "error");
} finally {
state.savingSchedule = false;
}
}
async function startDownload() {
if (state.actionBusy || state.status.downloading || state.status.isOnroad || state.selectedDraft.length === 0) {
return;
}
state.actionBusy = true;
try {
const payload = await fetchJson("/api/maps/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
selectedLocations: state.selectedDraft,
schedule: state.scheduleDraft,
}),
});
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map download started.");
} catch (error) {
notify(error?.message || "Failed to start map download.", "error");
} finally {
state.actionBusy = false;
}
}
async function cancelDownload() {
if (state.actionBusy || !state.status.downloading) return;
state.actionBusy = true;
try {
const payload = await fetchJson("/api/maps/cancel", { method: "POST" });
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Map download cancellation requested.");
} catch (error) {
notify(error?.message || "Failed to cancel map download.", "error");
} finally {
state.actionBusy = false;
}
}
async function removeMaps() {
if (state.actionBusy || state.status.downloading || state.status.isOnroad || !state.status.mapsPresent) {
return;
}
state.actionBusy = true;
try {
const payload = await fetchJson("/api/maps/remove", { method: "POST" });
if (payload.status) {
applyStatus(payload.status);
}
notify(payload.message || "Maps removed.");
} catch (error) {
notify(error?.message || "Failed to remove maps.", "error");
} finally {
state.actionBusy = false;
}
}
function resetDraftSelection() {
state.selectedDraft = [...state.selectedSaved];
}
function clearDraftSelection() {
state.selectedDraft = [];
}
function getVisibleRegions(group) {
const query = state.search.trim().toLowerCase();
if (!query) return group.regions || [];
return (group.regions || []).filter((region) => {
const haystack = `${region.label} ${region.code} ${region.token}`.toLowerCase();
return haystack.includes(query);
});
}
function getVisibleSections() {
return state.catalogSections.map((section) => {
const groups = (section.groups || [])
.map((group) => ({ ...group, visibleRegions: getVisibleRegions(group) }))
.filter((group) => group.visibleRegions.length > 0);
return { ...section, groups };
}).filter((section) => section.groups.length > 0);
}
function selectedCountForGroup(group) {
const groupTokens = new Set((group.regions || []).map((region) => region.token));
return state.selectedDraft.filter((token) => groupTokens.has(token)).length;
}
function selectedCountForSection(section) {
const sectionTokens = new Set(section.groups.flatMap((group) => (group.regions || []).map((region) => region.token)));
return state.selectedDraft.filter((token) => sectionTokens.has(token)).length;
}
function renderSelectedSummary() {
if (state.selectedDraft.length === 0) {
return html`<p class="maps-muted">No regions selected.</p>`;
}
return html`
<div class="maps-chip-list">
${state.selectedDraft.map((token) => html`<span class="maps-chip">${getTokenLabel(token)}</span>`)}
</div>
`;
}
function renderGroup(group) {
const selectedCount = selectedCountForGroup(group);
return html`
<article class="maps-group-card">
<div class="maps-group-header">
<div>
<h3>${group.title}</h3>
<p>${selectedCount} selected</p>
</div>
<div class="maps-group-actions">
<button class="maps-btn maps-btn-secondary maps-btn-small" @click="${() => setGroup(group, true)}">Select All</button>
<button class="maps-btn maps-btn-secondary maps-btn-small" @click="${() => setGroup(group, false)}">Clear</button>
</div>
</div>
<div class="maps-region-list">
${group.visibleRegions.map((region) => html`
<label class="maps-region-row">
<input
type="checkbox"
checked="${() => state.selectedDraft.includes(region.token)}"
@change="${(event) => toggleToken(region.token, event.target.checked)}"
/>
<span class="maps-region-label">${region.label}</span>
<span class="maps-region-code">${region.code}</span>
</label>
`)}
</div>
</article>
`;
}
export function MapsManager() {
if (!state.fetched) {
state.fetched = true;
initializeMaps();
}
ensurePolling();
const visibleSections = () => getVisibleSections();
return html`
<div class="maps-page">
<section class="maps-card maps-hero">
<div class="maps-hero-copy">
<h1>Maps</h1>
<p>Select regions, start downloads, and manage offline maps entirely from Galaxy.</p>
</div>
<div class="maps-status-grid">
<div class="maps-stat">
<span class="maps-stat-label">Downloader</span>
<span class="maps-stat-value">${() => state.loadingStatus ? "Checking..." : (state.status.downloading ? (state.status.cancelling ? "Cancelling" : "Downloading") : "Idle")}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Saved Regions</span>
<span class="maps-stat-value">${() => state.status.selectedCount}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Last Updated</span>
<span class="maps-stat-value">${() => state.status.lastUpdate}</span>
</div>
<div class="maps-stat">
<span class="maps-stat-label">Storage Used</span>
<span class="maps-stat-value">${() => formatBytes(state.status.storageBytes)}</span>
</div>
</div>
${() => state.error ? html`<p class="maps-error">${state.error}</p>` : ""}
${() => state.status.isOnroad ? html`<p class="maps-warning">Map downloads and removal are blocked while driving.</p>` : ""}
${() => selectionDirty() ? html`<p class="maps-warning">You have unsaved region changes. Downloading now will use the current Galaxy selection.</p>` : ""}
${() => scheduleDirty() ? html`<p class="maps-warning">You have an unsaved schedule change. Downloading now will also apply it.</p>` : ""}
<div class="maps-action-row">
<button
class="maps-btn maps-btn-primary"
@click="${() => state.status.downloading ? cancelDownload() : startDownload()}"
disabled="${() => state.loadingStatus || state.actionBusy || state.status.isOnroad || (!state.status.downloading && state.selectedDraft.length === 0)}"
>
${() => state.status.downloading ? (state.status.cancelling ? "Cancelling..." : "Cancel Download") : "Download Maps"}
</button>
<button
class="maps-btn maps-btn-danger"
@click="${removeMaps}"
disabled="${() => state.loadingStatus || state.actionBusy || state.status.downloading || state.status.isOnroad || !state.status.mapsPresent}"
>Remove Maps</button>
<button
class="maps-btn maps-btn-secondary"
@click="${saveSelection}"
disabled="${() => state.loadingCatalog || state.savingSelection || !selectionDirty()}"
>${() => state.savingSelection ? "Saving..." : "Save Selection"}</button>
<button class="maps-btn maps-btn-secondary" @click="${resetDraftSelection}" disabled="${() => !selectionDirty()}">Reset</button>
<button class="maps-btn maps-btn-secondary" @click="${clearDraftSelection}" disabled="${() => state.selectedDraft.length === 0}">Clear All</button>
</div>
</section>
<section class="maps-card maps-toolbar-card">
<div class="maps-toolbar-row">
<label class="maps-field maps-search-field">
<span>Search Regions</span>
<input
class="maps-input"
type="search"
placeholder="Filter by name or code"
value="${() => state.search}"
@input="${(event) => { state.search = event.target.value; }}"
/>
</label>
<label class="maps-field maps-schedule-field">
<span>Auto Update Schedule</span>
<select class="maps-select" value="${() => state.scheduleDraft}" @change="${(event) => { state.scheduleDraft = event.target.value; }}">
${() => state.scheduleOptions.map((option) => html`<option value="${option.value}">${option.label}</option>`)}
</select>
</label>
<button class="maps-btn maps-btn-secondary maps-schedule-button" @click="${saveSchedule}" disabled="${() => state.savingSchedule || !scheduleDirty()}">
${() => state.savingSchedule ? "Applying..." : "Apply Schedule"}
</button>
</div>
<div class="maps-toolbar-meta">
<span class="maps-muted">Saved schedule: ${() => state.status.scheduleLabel}</span>
<span class="maps-muted">Draft regions: ${() => state.selectedDraft.length}</span>
</div>
<div class="maps-selected-summary">
<h2>Selected Regions</h2>
${() => renderSelectedSummary()}
</div>
</section>
${() => state.loadingCatalog ? html`<section class="maps-card"><p class="maps-muted">Loading map catalog...</p></section>` : ""}
${() => !state.loadingCatalog && visibleSections().length === 0 ? html`<section class="maps-card"><p class="maps-muted">No regions match the current search.</p></section>` : ""}
${() => visibleSections().map((section) => html`
<section class="maps-card maps-section-card">
<div class="maps-section-header">
<div>
<h2>${section.title}</h2>
<p>${selectedCountForSection(section)} selected</p>
</div>
</div>
<div class="maps-group-grid">
${section.groups.map((group) => renderGroup(group))}
</div>
</section>
`)}
</div>
`;
}