diff --git a/photomap/frontend/static/css/umap-floating-window.css b/photomap/frontend/static/css/umap-floating-window.css index 37b0d20a..6535a655 100644 --- a/photomap/frontend/static/css/umap-floating-window.css +++ b/photomap/frontend/static/css/umap-floating-window.css @@ -61,6 +61,60 @@ font-size: 1em; } +.umap-titlebar-left { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + /* Was on the select itself before the reindex button joined it. */ + max-width: 60%; +} + +#umapReindexBtn { + display: inline-flex; + align-items: center; + padding: 4px; + margin: 0; +} + +/* Progress ring shown in place of the reindex button while indexing runs. + The fill arc is driven from JS via stroke-dashoffset (circumference 2πr, + r=8 → 50.27); the -90° rotation starts the arc at 12 o'clock. */ +.umap-reindex-progress { + display: inline-flex; + align-items: center; + padding: 4px; + cursor: default; +} + +.umap-reindex-progress svg { + transform: rotate(-90deg); +} + +.umap-reindex-track { + fill: none; + stroke: rgba(255, 255, 255, 0.25); + stroke-width: 3; +} + +.umap-reindex-ring { + fill: none; + stroke: #ff9800; + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 50.27; + stroke-dashoffset: 50.27; + transition: + stroke-dashoffset 0.3s, + stroke 0.3s; +} + +/* Phases without a meaningful percentage (directory traversal) show a + spinning quarter arc instead of a fill level. Keyframes: spinners.css. */ +.umap-reindex-progress.indeterminate svg { + animation: umap-spin 1.2s linear infinite; +} + #semanticMapAlbumSelect { cursor: pointer; background: transparent; @@ -72,7 +126,8 @@ border-radius: 4px; padding: 2px 22px 2px 6px; margin: 0; - max-width: 60%; + max-width: 100%; + min-width: 0; text-overflow: ellipsis; appearance: none; -webkit-appearance: none; diff --git a/photomap/frontend/static/javascript/umap-reindex.js b/photomap/frontend/static/javascript/umap-reindex.js new file mode 100644 index 00000000..d9bfa8d3 --- /dev/null +++ b/photomap/frontend/static/javascript/umap-reindex.js @@ -0,0 +1,210 @@ +// umap-reindex.js +// The 🔄 button in the semantic-map titlebar: a shortcut for "Update Index" +// on the current album without opening Album Management. While an index +// update runs, the button is swapped for a small progress ring whose fill +// tracks progress_percentage, whose colour tracks the phase (matching the +// Album Manager's status colours), and whose hover title carries the live +// status text. Clicking the ring does nothing — cancellation stays in the +// Album Manager. On completion an "albumIndexUpdated" event is dispatched +// so the map can reload itself. + +import { updateIndex } from "./index.js"; +import { state } from "./state.js"; +import { fetchJson } from "./utils.js"; + +// Mutable so tests can shorten the poll cadence. +export const reindexConfig = { + pollInterval: 1000, + maxPollFailures: 5, +}; + +const RUNNING_STATUSES = ["scanning", "downloading", "indexing", "mapping"]; + +// Same palette as the Album Manager's status lines. +const PHASE_COLORS = { + scanning: "#ff9800", + indexing: "#ff9800", + downloading: "#9c27b0", + mapping: "#2196f3", +}; + +const RING_CIRCUMFERENCE = 50.27; // 2πr for the r=8 ring in the template + +let pollTimer = null; + +function elements() { + return { + btn: document.getElementById("umapReindexBtn"), + progress: document.getElementById("umapReindexProgress"), + ring: document.getElementById("umapReindexRing"), + }; +} + +function showRing() { + const { btn, progress } = elements(); + if (btn) { + btn.style.display = "none"; + } + if (progress) { + progress.style.display = "inline-flex"; + } +} + +function showButton() { + const { btn, progress } = elements(); + if (btn) { + btn.style.display = ""; + } + if (progress) { + progress.style.display = "none"; + } +} + +function updateRing(progressData) { + const { progress, ring } = elements(); + if (!progress || !ring) { + return; + } + + const status = progressData.status; + ring.style.stroke = PHASE_COLORS[status] || "#ff9800"; + + // The traversal phase reports counts, not a completion fraction — show a + // spinning quarter arc there and a real fill everywhere else. + const indeterminate = status === "scanning"; + progress.classList.toggle("indeterminate", indeterminate); + let percentage = Number(progressData.progress_percentage); + if (!Number.isFinite(percentage)) { + percentage = 0; + } + percentage = Math.min(100, Math.max(0, percentage)); + const fraction = indeterminate ? 0.25 : percentage / 100; + ring.style.strokeDashoffset = String(RING_CIRCUMFERENCE * (1 - fraction)); + + // Native title tooltip: shows the live status text on hover. + const step = progressData.current_step || "Indexing in progress..."; + const suffix = indeterminate ? "" : ` (${Math.round(percentage)}%)`; + progress.title = `${step}${suffix}`; +} + +function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + showButton(); +} + +// Poll the backend for this run's progress until it leaves a running state. +// Mirrors the Album Manager's card polling, but scoped to the titlebar ring. +function beginProgress(albumKey, initialProgress = null) { + if (pollTimer) { + return; + } + showRing(); + if (initialProgress) { + updateRing(initialProgress); + } + + let consecutiveFailures = 0; + pollTimer = setInterval(async () => { + try { + const progress = await fetchJson(`index_progress/${albumKey}`); + consecutiveFailures = 0; + if (RUNNING_STATUSES.includes(progress.status)) { + updateRing(progress); + return; + } + stopPolling(); + if (progress.status === "completed") { + window.dispatchEvent(new CustomEvent("albumIndexUpdated", { detail: { albumKey } })); + } else if (progress.status === "error") { + const { btn } = elements(); + if (btn) { + btn.title = `Index update failed: ${progress.error_message || "unknown error"} — click to retry`; + } + } + } catch (error) { + consecutiveFailures += 1; + if (consecutiveFailures >= reindexConfig.maxPollFailures) { + console.error(`Giving up polling index progress for album ${albumKey}:`, error); + stopPolling(); + } + } + }, reindexConfig.pollInterval); +} + +export async function startUmapReindex() { + const albumKey = state.album; + if (!albumKey || pollTimer) { + return; + } + + // If an update is already running (Album Manager, Update All, another + // tab), attach the ring to that run instead of starting a duplicate. + try { + const progress = await fetchJson(`index_progress/${albumKey}`); + if (RUNNING_STATUSES.includes(progress.status)) { + beginProgress(albumKey, progress); + return; + } + } catch { + // Progress endpoint unreachable — fall through and let updateIndex() + // surface any real error to the user. + } + + const response = await updateIndex(albumKey); // alerts + returns null on failure + if (!response) { + return; + } + beginProgress(albumKey); +} + +// Attach the ring to an already-running update for the current album (e.g. +// one started from Album Management before this window was opened). Called +// when the semantic map is shown and when the album changes. +export async function checkUmapReindexOngoing() { + const albumKey = state.album; + if (!albumKey) { + return; + } + if (pollTimer) { + // A poller from a previous album may still be running after an album + // switch; restart cleanly against the current album. + stopPolling(); + } + try { + const progress = await fetchJson(`index_progress/${albumKey}`); + if (RUNNING_STATUSES.includes(progress.status)) { + beginProgress(albumKey, progress); + } + } catch { + // No progress info — leave the plain button in place. + } +} + +export function initUmapReindexButton() { + const { btn, progress } = elements(); + if (!btn) { + return; + } + // The titlebar is draggable; keep pointer events on the button (and the + // ring) from starting a drag, the same way the album select does. + for (const el of [btn, progress]) { + if (!el) { + continue; + } + for (const evt of ["mousedown", "touchstart", "dblclick"]) { + el.addEventListener(evt, (e) => e.stopPropagation()); + } + } + btn.addEventListener("click", (e) => { + e.stopPropagation(); + btn.title = "Update this album's index"; + startUmapReindex(); + }); + + window.addEventListener("albumChanged", () => { + checkUmapReindexOngoing(); + }); +} diff --git a/photomap/frontend/static/javascript/umap.js b/photomap/frontend/static/javascript/umap.js index fc1e14db..dde8ccca 100644 --- a/photomap/frontend/static/javascript/umap.js +++ b/photomap/frontend/static/javascript/umap.js @@ -22,6 +22,7 @@ import { state, } from "./state.js"; import { findLandmarkClusterAt } from "./umap-helpers.js"; +import { checkUmapReindexOngoing, initUmapReindexButton } from "./umap-reindex.js"; import { debounce, getPercentile, isColorLight, makeDraggable } from "./utils.js"; const UMAP_SIZES = { @@ -1600,6 +1601,10 @@ export async function toggleUmapWindow(show = null) { return; } + // If an index update is already running for this album (e.g. started + // from Album Management), show the titlebar progress ring for it. + checkUmapReindexOngoing(); + // Fetch configured eps value from server const result = await fetch("get_umap_eps/", { method: "POST", @@ -2021,8 +2026,18 @@ export function isUmapFullscreen() { document.addEventListener("DOMContentLoaded", () => { setupSemanticMapAlbumSelect(); populateSemanticMapAlbumSelect(); + initUmapReindexButton(); initializeUmapWindow(); }); + +// The titlebar reindex button finished an update of the current album: the +// map on screen is stale, so reload it. +window.addEventListener("albumIndexUpdated", (e) => { + if (e.detail?.albumKey === state.album) { + state.dataChanged = true; + fetchUmapData(); + } +}); window.addEventListener("albumChanged", initializeUmapWindow); // ======================================================== diff --git a/photomap/frontend/templates/modules/umap-floating-window.html b/photomap/frontend/templates/modules/umap-floating-window.html index 43380286..359cc69e 100644 --- a/photomap/frontend/templates/modules/umap-floating-window.html +++ b/photomap/frontend/templates/modules/umap-floating-window.html @@ -4,7 +4,38 @@
- +
+ + + + +
+ `; +} + +async function waitForButtonRestore() { + for (let i = 0; i < 40; i++) { + await flush(); + if (document.getElementById("umapReindexBtn").style.display === "") { + return; + } + } + throw new Error("button never restored"); +} + +beforeEach(async () => { + buildDom(); + updateIndex.mockReset(); + fetchJson.mockReset(); + state.album = "alb"; + // Drain any poller left over from a previous test. + fetchJson.mockResolvedValue({ status: "completed" }); + await flush(); +}); + +describe("startUmapReindex", () => { + test("starts an update, swaps to the ring, and restores on completion", async () => { + const statuses = [ + { status: "idle" }, // pre-start guard check + { status: "indexing", progress_percentage: 50, current_step: "Indexing images" }, + { status: "completed" }, + ]; + fetchJson.mockImplementation(() => Promise.resolve(statuses.shift() ?? { status: "completed" })); + updateIndex.mockResolvedValue({ success: true }); + const events = []; + const onUpdated = (e) => events.push(e.detail.albumKey); + window.addEventListener("albumIndexUpdated", onUpdated); + try { + await startUmapReindex(); + expect(updateIndex).toHaveBeenCalledWith("alb"); + expect(document.getElementById("umapReindexBtn").style.display).toBe("none"); + expect(document.getElementById("umapReindexProgress").style.display).toBe("inline-flex"); + + await waitForButtonRestore(); + expect(events).toEqual(["alb"]); + expect(document.getElementById("umapReindexProgress").style.display).toBe("none"); + } finally { + window.removeEventListener("albumIndexUpdated", onUpdated); + } + }); + + test("attaches to an already-running update instead of starting one", async () => { + fetchJson.mockResolvedValueOnce({ status: "scanning", current_step: "Traversing image files..." }); + fetchJson.mockResolvedValue({ status: "completed" }); + + await startUmapReindex(); + + expect(updateIndex).not.toHaveBeenCalled(); + expect(document.getElementById("umapReindexProgress").style.display).toBe("inline-flex"); + await waitForButtonRestore(); + }); + + test("failed start (updateIndex null) leaves the plain button in place", async () => { + fetchJson.mockResolvedValue({ status: "idle" }); + updateIndex.mockResolvedValue(null); + + await startUmapReindex(); + + expect(document.getElementById("umapReindexBtn").style.display).toBe(""); + expect(document.getElementById("umapReindexProgress").style.display).toBe("none"); + }); + + test("error status restores the button and surfaces the message in its title", async () => { + fetchJson.mockResolvedValueOnce({ status: "idle" }); + fetchJson.mockResolvedValue({ status: "error", error_message: "boom" }); + updateIndex.mockResolvedValue({ success: true }); + + await startUmapReindex(); + await waitForButtonRestore(); + + expect(document.getElementById("umapReindexBtn").title).toContain("boom"); + }); +}); + +describe("progress ring rendering", () => { + test("shows phase colour, fill fraction, and hover title from the poll", async () => { + fetchJson.mockResolvedValueOnce({ status: "idle" }); + fetchJson.mockResolvedValueOnce({ + status: "mapping", + progress_percentage: 75, + current_step: "Generating image map...", + }); + fetchJson.mockResolvedValue({ status: "completed" }); + updateIndex.mockResolvedValue({ success: true }); + + await startUmapReindex(); + await flush(); + + const ring = document.getElementById("umapReindexRing"); + const progress = document.getElementById("umapReindexProgress"); + expect(ring.style.stroke).toBe("#2196f3"); // mapping = blue, as in Album Manager + // 75% fill: dashoffset = C * 0.25 + expect(Number.parseFloat(ring.style.strokeDashoffset)).toBeCloseTo(50.27 * 0.25, 1); + expect(progress.title).toBe("Generating image map... (75%)"); + await waitForButtonRestore(); + }); + + test("scanning phase renders as an indeterminate spinning arc", async () => { + fetchJson.mockResolvedValueOnce({ status: "idle" }); + fetchJson.mockResolvedValueOnce({ + status: "scanning", + progress_percentage: 100, + current_step: "Traversing image files... 5000 found", + }); + fetchJson.mockResolvedValue({ status: "completed" }); + updateIndex.mockResolvedValue({ success: true }); + + await startUmapReindex(); + await flush(); + + const progress = document.getElementById("umapReindexProgress"); + expect(progress.classList.contains("indeterminate")).toBe(true); + expect(progress.title).toBe("Traversing image files... 5000 found"); + await waitForButtonRestore(); + }); +}); + +describe("checkUmapReindexOngoing", () => { + test("shows the ring when the backend reports a run, stays idle otherwise", async () => { + fetchJson.mockResolvedValueOnce({ status: "indexing", progress_percentage: 10 }); + fetchJson.mockResolvedValue({ status: "completed" }); + + await checkUmapReindexOngoing(); + expect(document.getElementById("umapReindexProgress").style.display).toBe("inline-flex"); + await waitForButtonRestore(); + + fetchJson.mockResolvedValue({ status: "idle" }); + await checkUmapReindexOngoing(); + expect(document.getElementById("umapReindexProgress").style.display).toBe("none"); + }); +});