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, downloadProgress: { active: false, cancelled: false, completed: false, downloadedBytes: 0, downloadedFiles: 0, estimatedDownloadBytes: 0, estimateSource: "", etaSeconds: 0, percent: 0, phase: "idle", primaryLocation: "", totalFiles: 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 formatDuration(seconds) { const value = Math.max(0, Math.round(Number(seconds || 0))); if (value < 60) return `${value}s`; const minutes = Math.floor(value / 60); const remainingSeconds = value % 60; if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; const hours = Math.floor(minutes / 60); return `${hours}h ${minutes % 60}m`; } function normalizeDownloadProgress(progress) { const value = progress && typeof progress === "object" ? progress : {}; return { active: Boolean(value.active), cancelled: Boolean(value.cancelled), completed: Boolean(value.completed), downloadedBytes: Number(value.downloadedBytes || 0), downloadedFiles: Number(value.downloadedFiles || 0), estimatedDownloadBytes: Number(value.estimatedDownloadBytes || 0), estimateSource: String(value.estimateSource || ""), etaSeconds: Number(value.etaSeconds || 0), percent: Math.max(0, Math.min(100, Number(value.percent || 0))), phase: String(value.phase || "idle"), primaryLocation: String(value.primaryLocation || ""), totalFiles: Number(value.totalFiles || 0), }; } 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), downloadProgress: normalizeDownloadProgress(payload.downloadProgress), }; 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`

No regions selected.

`; } return html`
${state.selectedDraft.map((token) => html`${getTokenLabel(token)}`)}
`; } function downloadSizeLabel() { const progress = state.status.downloadProgress; if (!selectionDirty() && progress.estimatedDownloadBytes > 0) { return `~${formatBytes(progress.estimatedDownloadBytes)}`; } if (state.selectedDraft.length > 0) { return "Calculated during download"; } return "Select regions"; } function renderDownloadProgress() { const progress = state.status.downloadProgress; const visible = state.status.downloading || (!selectionDirty() && (progress.completed || progress.cancelled || progress.estimatedDownloadBytes > 0)); if (!visible) return ""; const isActive = state.status.downloading; const title = isActive ? "Download Progress" : progress.completed ? "Last Download" : progress.cancelled ? "Download Cancelled" : "Download Estimate"; const sizeLabel = progress.estimatedDownloadBytes > 0 ? `~${formatBytes(progress.estimatedDownloadBytes)} total` : "Calculating total size..."; const storedLabel = progress.downloadedBytes > 0 ? `${formatBytes(progress.downloadedBytes)} stored` : "No files stored yet"; const filesLabel = progress.totalFiles > 0 ? `${progress.downloadedFiles} / ${progress.totalFiles} files` : "Waiting for map service..."; const etaLabel = isActive && progress.etaSeconds > 0 ? `About ${formatDuration(progress.etaSeconds)} remaining` : "ETA unavailable until files start arriving"; const sourceLabel = progress.estimateSource === "previous_download" ? "Estimate based on the last download of this exact selection." : "Size is estimated from the map files as they arrive."; return html`
${title} ${Math.round(progress.percent)}%
${sizeLabel} ${storedLabel} ${filesLabel} ${etaLabel}
${progress.primaryLocation ? html`

Current region: ${progress.primaryLocation}

` : ""}

${sourceLabel}

`; } function renderGroup(group) { const selectedCount = selectedCountForGroup(group); return html`

${group.title}

${selectedCount} selected

${group.visibleRegions.map((region) => html` `)}
`; } export function MapsManager() { if (!state.fetched) { state.fetched = true; initializeMaps(); } ensurePolling(); const visibleSections = () => getVisibleSections(); return html`

Maps

Select regions, start downloads, and manage offline maps entirely from Galaxy.

Downloader ${() => state.loadingStatus ? "Checking..." : (state.status.downloading ? (state.status.cancelling ? "Cancelling" : "Downloading") : "Idle")}
Saved Regions ${() => state.status.selectedCount}
Download Size ${() => downloadSizeLabel()}
Last Updated ${() => state.status.lastUpdate}
Storage Used ${() => formatBytes(state.status.storageBytes)}
${() => state.error ? html`

${state.error}

` : ""} ${() => state.status.isOnroad ? html`

Map downloads and removal are blocked while driving.

` : ""} ${() => selectionDirty() ? html`

You have unsaved region changes. Downloading now will use the current Galaxy selection.

` : ""} ${() => scheduleDirty() ? html`

You have an unsaved schedule change. Downloading now will also apply it.

` : ""} ${() => renderDownloadProgress()}
Saved schedule: ${() => state.status.scheduleLabel} Draft regions: ${() => state.selectedDraft.length}

Selected Regions

${() => renderSelectedSummary()}
${() => state.loadingCatalog ? html`

Loading map catalog...

` : ""} ${() => !state.loadingCatalog && visibleSections().length === 0 ? html`

No regions match the current search.

` : ""} ${() => visibleSections().map((section) => html`

${section.title}

${selectedCountForSection(section)} selected

${section.groups.map((group) => renderGroup(group))}
`)}
`; }