From 3526ecdc0edbbd69de1f351a6bcfaaef722cb000 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 4 Jul 2026 21:20:48 -0400 Subject: [PATCH] feat: reindex button with progress ring in the semantic map titlebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small refresh button (two circling arrows, matching the titlebar icon style) sits left of the album pulldown and starts an index update for the current album — a shortcut for opening Album Management. While the update runs the button is swapped for a progress ring that fills with the real percentage and takes the Album Manager's phase colours (orange scan/index, purple download, blue UMAP); the traversal phase spins an indeterminate arc. Hovering the ring shows the live status text. Opening the map or switching albums attaches the ring to an update that is already running elsewhere (Album Management, another tab) instead of starting a duplicate; clicking during a run does nothing — cancellation deliberately stays in Album Management. On completion an albumIndexUpdated event fires and the map reloads itself; on error the button returns with the message in its tooltip and can be clicked to retry. Co-Authored-By: Claude Fable 5 --- .../static/css/umap-floating-window.css | 57 ++++- .../static/javascript/umap-reindex.js | 210 ++++++++++++++++++ photomap/frontend/static/javascript/umap.js | 15 ++ .../modules/umap-floating-window.html | 33 ++- tests/frontend/umap-reindex.test.js | 173 +++++++++++++++ 5 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 photomap/frontend/static/javascript/umap-reindex.js create mode 100644 tests/frontend/umap-reindex.test.js 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"); + }); +});