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
4 changes: 2 additions & 2 deletions photomap/frontend/static/css/control-and-search-panels.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5em;
gap: 0.2em;
z-index: 4000;
background: rgba(30, 30, 30, 0.35);
border-radius: 0.7em;
Expand Down Expand Up @@ -69,7 +69,7 @@
font-size: 1em;
font-weight: bold;
text-align: center;
margin-bottom: 0.2em;
margin-bottom: 0;
letter-spacing: 0.5px;
width: 100%;
display: block;
Expand Down
33 changes: 30 additions & 3 deletions photomap/frontend/static/css/umap-floating-window.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@
padding-bottom: 20px;
}

#umapColorModeContainer {
min-width: 180px;
}

.icon-btn {
background: none;
Expand Down Expand Up @@ -118,6 +115,36 @@
font-size: 0.85em;
}

.umap-info-btn {
-webkit-appearance: none;
appearance: none;
background: rgba(10, 20, 55, 0.85);
border: 1.5px solid #4a9eff;
border-radius: 50%;
width: 1.3em;
height: 1.3em;
padding: 0;
cursor: pointer;
color: #4a9eff;
font-size: 0.85em;
font-style: italic;
font-weight: bold;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
transition: background 0.15s, color 0.15s;
}

.umap-info-btn:hover,
.umap-info-btn:focus {
background: rgba(20, 50, 120, 0.9);
color: #80c0ff;
border-color: #80c0ff;
outline: none;
}

#umapClickBehaviorContainer {
font-size: 0.85em;
}
Expand Down
15 changes: 15 additions & 0 deletions photomap/frontend/static/javascript/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const state = {
umapShowHoverThumbnails: true, // Show hover thumbnails in UMAP
umapExitFullscreenOnSelection: true, // Exit fullscreen when cluster is selected
umapClickSelectsCluster: true, // Whether click selects cluster or single image
umapControlsVisible: true, // Whether the UMAP controls panel is visible
};

document.addEventListener("DOMContentLoaded", async () => {
Expand Down Expand Up @@ -137,6 +138,11 @@ export async function restoreFromLocalStorage() {
if (storedUmapClickSelectsCluster !== null) {
state.umapClickSelectsCluster = storedUmapClickSelectsCluster === "true";
}

const storedUmapControlsVisible = localStorage.getItem("umapControlsVisible");
if (storedUmapControlsVisible !== null) {
state.umapControlsVisible = storedUmapControlsVisible === "true";
}
}

// Save state to local storage
Expand All @@ -154,6 +160,7 @@ export function saveSettingsToLocalStorage() {
localStorage.setItem("umapShowHoverThumbnails", state.umapShowHoverThumbnails ? "true" : "false");
localStorage.setItem("umapExitFullscreenOnSelection", state.umapExitFullscreenOnSelection ? "true" : "false");
localStorage.setItem("umapClickSelectsCluster", state.umapClickSelectsCluster ? "true" : "false");
localStorage.setItem("umapControlsVisible", state.umapControlsVisible ? "true" : "false");
}

export async function setAlbum(newAlbumKey, force = false) {
Expand Down Expand Up @@ -261,3 +268,11 @@ export function setUmapClickSelectsCluster(clickSelectsCluster) {
);
}
}

export function setUmapControlsVisible(visible) {
if (state.umapControlsVisible !== visible) {
state.umapControlsVisible = visible;
saveSettingsToLocalStorage();
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: { umapControlsVisible: visible } }));
}
}
130 changes: 120 additions & 10 deletions photomap/frontend/static/javascript/umap.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
// umap.js
// This file handles the UMAP visualization and interaction logic.
import { albumManager } from "./album-manager.js";
import { CLUSTER_PALETTE } from "./cluster-utils.js";
import { exitSearchMode } from "./search-ui.js";
import { getImagePath, setSearchResults } from "./search.js";
import { getCurrentSlideIndex, slideState } from "./slide-state.js";
import {
setUmapClickSelectsCluster,
setUmapControlsVisible,
setUmapExitFullscreenOnSelection,
setUmapShowHoverThumbnails,
setUmapShowLandmarks,
setUmapClickSelectsCluster,
state,
} from "./state.js";
import { debounce, getPercentile, isColorLight } from "./utils.js";
import { CLUSTER_PALETTE } from "./cluster-utils.js";

const UMAP_SIZES = {
big: { width: 800, height: 590 },
Expand Down Expand Up @@ -238,9 +239,25 @@ export async function fetchUmapData() {
scrollZoom: true,
};

const isFirstRender = !mapExists;
// Save current plot dimensions before Plotly.newPlot resets them to the layout defaults.
const umapPlotDiv = document.getElementById("umapPlot");
const savedPlotWidth = parseInt(umapPlotDiv.style.width, 10);
const savedPlotHeight = parseInt(umapPlotDiv.style.height, 10);
Plotly.newPlot("umapPlot", [allPointsTrace, currentImageTrace], layout, config).then(async (gd) => {
document.getElementById("umapContent").style.display = "block";
setUmapWindowSize("fullscreen");
applyUmapControlsVisibility();
if (isFirstRender) {
setUmapWindowSize("fullscreen");
} else if (isShaded) {
setUmapWindowSize(lastUnshadedSize);
} else if (savedPlotWidth > 0 && savedPlotHeight > 0) {
// Recalculation: only resize the Plotly plot back to its pre-newPlot dimensions,
// leaving the window container size and position untouched.
Plotly.relayout(gd, { width: savedPlotWidth, height: savedPlotHeight });
} else {
setUmapWindowSize(lastUnshadedSize);
}
hideUmapSpinner();

window.dispatchEvent(new CustomEvent("umapRedrawn"));
Expand Down Expand Up @@ -1319,6 +1336,19 @@ async function handleImageClick(clickedIndex) {

// -------------------- Window Management --------------------

function applyUmapControlsVisibility() {
const controls = document.getElementById("umapControls");
const btn = document.getElementById("umapToggleControlsBtn");
const visible = state.umapControlsVisible;
if (controls) {
controls.style.display = visible ? "" : "none";
}
if (btn) {
btn.style.opacity = visible ? "1" : "0.35";
btn.title = visible ? "Hide controls" : "Show controls";
}
}

// --- Show/Hide UMAP Window ---
export async function toggleUmapWindow(show = null) {
const umapWindow = document.getElementById("umapFloatingWindow");
Expand All @@ -1331,6 +1361,7 @@ export async function toggleUmapWindow(show = null) {
umapWindow.style.display = "none";
} else {
umapWindow.style.display = "block";
applyUmapControlsVisibility();
ensureUmapWindowInView();
if (!umapWindowHasBeenShown) {
umapWindowHasBeenShown = true;
Expand Down Expand Up @@ -1364,6 +1395,14 @@ document.getElementById("showUmapBtn").onclick = () => toggleUmapWindow();
document.getElementById("umapCloseBtn").onclick = () => {
document.getElementById("umapFloatingWindow").style.display = "none";
};
document.getElementById("umapToggleControlsBtn").onclick = () => {
setUmapControlsVisible(!state.umapControlsVisible);
applyUmapControlsVisibility();
if (!isShaded) {
saveCurrentPosition(); // anchor to current position before resizing
setUmapWindowSize(getCurrentWindowSize());
}
};

// --- Draggable Window ---
function makeDraggable(dragHandleId, windowId) {
Expand Down Expand Up @@ -1480,19 +1519,26 @@ function setUmapWindowSize(sizeKey) {
if (contentDiv) {
contentDiv.style.display = "block";
}
// controlsHeight: space reserved below plot for UMAP controls (~120px with radio buttons)
// plus clearance for bottom ControlPanel/SearchPanel (~110px)
const controlsHeight = 230;
// controlsHeight: space reserved below the plot for UMAP controls
const controlsHeight = state.umapControlsVisible ? 110 : 40;
// Measure how much vertical space the bottom Control/Search panels occupy.
// The window covers the full viewport; its dark background fills the dead zone behind the panels.
const bottomPanel = document.getElementById("controlPanel");
const panelReservedHeight = bottomPanel
? Math.round(window.innerHeight - bottomPanel.getBoundingClientRect().top) + 16
: 96; // +16 ≈ 1em gap above the panels
const windowHeight = window.innerHeight;
win.style.left = "0px";
win.style.top = "0px";
win.style.width = window.innerWidth + "px";
win.style.height = (narrowScreen ? window.innerHeight - 120 : window.innerHeight) + "px";
win.style.height = windowHeight + "px";
win.style.minHeight = "200px";
win.style.maxWidth = "100vw";
win.style.maxHeight = "100vh";
win.style.opacity = "1";
const plotHeight = windowHeight - controlsHeight - panelReservedHeight;
plotDiv.style.width = window.innerWidth - 32 + "px";
plotDiv.style.height = window.innerHeight - controlsHeight + "px";
plotDiv.style.height = plotHeight + "px";
if (narrowScreen) {
// Change positioning of the controls
contentDiv.style.position = "relative";
Expand All @@ -1504,7 +1550,7 @@ function setUmapWindowSize(sizeKey) {
if (plotDiv.data && plotDiv.data.length > 0) {
Plotly.relayout(plotDiv, {
width: window.innerWidth - 32,
height: window.innerHeight - controlsHeight,
height: plotHeight,
"xaxis.scaleanchor": "y",
});
}
Expand All @@ -1514,8 +1560,10 @@ function setUmapWindowSize(sizeKey) {
}
const { width, height } = UMAP_SIZES[sizeKey];
const bottomPadding = 8; // add breathing room under plot
const extraWindowHeight = state.umapControlsVisible ? 130 : 60;
const desiredWindowHeight = height + extraWindowHeight + bottomPadding;
win.style.width = width + 60 + "px";
win.style.height = height + 120 + bottomPadding + "px"; // window taller
win.style.height = Math.min(desiredWindowHeight, window.innerHeight - 20) + "px"; // window taller, capped at viewport
win.style.minHeight = "200px";
plotDiv.style.width = width + "px";
plotDiv.style.height = height - bottomPadding + "px"; // plot area shorter
Expand Down Expand Up @@ -1640,6 +1688,68 @@ addButtonHandlers("umapCloseBtn", () => {
document.getElementById("umapFloatingWindow").style.display = "none";
});

// --- Cluster Info Modal ---
function showClusterInfoModal() {
const modal = document.getElementById("umapClusterInfoModal");
if (!modal) {
return;
}

// Compute stats from the module-level points array
const eps = parseFloat(document.getElementById("umapEpsSpinner").value);
const clustered = points.filter((p) => p.cluster !== -1);
const clusterIds = [...new Set(clustered.map((p) => p.cluster))];
const clusterCount = clusterIds.length;
const unclusteredCount = points.length - clustered.length;

let largestSize = 0;
let smallestSize = Infinity;
for (const id of clusterIds) {
const size = points.filter((p) => p.cluster === id).length;
if (size > largestSize) {
largestSize = size;
}
if (size < smallestSize) {
smallestSize = size;
}
}
if (clusterCount === 0) {
largestSize = 0;
smallestSize = 0;
}

document.getElementById("umapInfoEps").textContent = isNaN(eps) ? "—" : eps.toFixed(2);
document.getElementById("umapInfoClusterCount").textContent =
clusterCount === 1 ? "1 cluster" : `${clusterCount} clusters`;
document.getElementById("umapInfoLargest").textContent = largestSize === 1 ? "1 image" : `${largestSize} images`;
document.getElementById("umapInfoSmallest").textContent = smallestSize === 1 ? "1 image" : `${smallestSize} images`;
document.getElementById("umapInfoUnclustered").textContent =
unclusteredCount === 1 ? "1 image" : `${unclusteredCount} images`;
document.getElementById("umapInfoTotal").textContent = points.length === 1 ? "1 image" : `${points.length} images`;

modal.classList.add("visible");
}

function hideClusterInfoModal() {
const modal = document.getElementById("umapClusterInfoModal");
if (modal) {
modal.classList.remove("visible");
}
}

document.getElementById("umapClusterInfoBtn").addEventListener("click", (e) => {
e.stopPropagation();
showClusterInfoModal();
});

document.getElementById("umapClusterInfoClose").addEventListener("click", hideClusterInfoModal);

document.getElementById("umapClusterInfoModal").addEventListener("click", (e) => {
if (e.target === e.currentTarget) {
hideClusterInfoModal();
}
});

window.addEventListener("resize", () => {
// Only resize if UMAP window is in fullscreen mode
const win = document.getElementById("umapFloatingWindow");
Expand Down
Loading
Loading