Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion photomap/frontend/static/css/umap-floating-window.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
210 changes: 210 additions & 0 deletions photomap/frontend/static/javascript/umap-reindex.js
Original file line number Diff line number Diff line change
@@ -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();
});
}
15 changes: 15 additions & 0 deletions photomap/frontend/static/javascript/umap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);

// ========================================================
Expand Down
33 changes: 32 additions & 1 deletion photomap/frontend/templates/modules/umap-floating-window.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,38 @@
<div id="umapFloatingWindow">
<!-- Titlebar -->
<div id="umapTitlebar">
<select id="semanticMapAlbumSelect" title="Switch album" aria-label="Switch album"></select>
<div class="umap-titlebar-left">
<button id="umapReindexBtn" class="icon-btn" title="Update this album's index" aria-label="Update this album's index">
<!-- Two circling arrows, pale grey on the dark titlebar to match the
neighbouring resize icons; sized to the pulldown's text height. -->
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#d0d0d0"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
<path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
</svg>
</button>
<!-- Swapped in for the button while an index update runs. The ring
fills with progress_percentage; the title carries the status text
so hovering shows what the indexer is doing. -->
<span id="umapReindexProgress" class="umap-reindex-progress" style="display: none" role="progressbar">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
<circle class="umap-reindex-track" cx="10" cy="10" r="8" />
<circle id="umapReindexRing" class="umap-reindex-ring" cx="10" cy="10" r="8" />
</svg>
</span>
<select id="semanticMapAlbumSelect" title="Switch album" aria-label="Switch album"></select>
</div>
<div style="display: flex; align-items: center; gap: 3px">
<!-- Resizing icons -->
<button id="umapResizeFullscreen" title="Fullscreen" class="icon-btn">
Expand Down
Loading
Loading