Compare commits

...

5 Commits

Author SHA1 Message Date
LyAhn 619bd0c9d2 Merge: rename 'tag cloud' to 'visual clusters' across the stack
github/actions/ci GitHub Actions CI finished: success
Disambiguate the Explore feature naming: the visual k-means cluster view was
named tag_cloud/tagCloud/TagCloud everywhere while the actual tag list is
explore_tags, which was easy to confuse. The cluster side is now
visual_cluster/visualCluster/VisualCluster (command, types, store, DB cache
table, and the ExploreView component); the tags side and the user-facing
"Tag Cloud" label are unchanged. Includes a mock-fixture fix so the dev
"huge" scenario surfaces a realistic field of clusters.
2026-06-30 10:01:25 +01:00
LyAhn 996bb71375 fix(mock): scale visual-cluster fixtures to the scenario
The mock Explore clusters were a fixed 10 regardless of scenario, so the "huge"
dev scenario looked sparse next to its large tag vocabulary. Mirror the backend's
k = (n / 20).clamp(5, 30): the huge scenario now surfaces ~30 clusters with a
long-tailed, big-library-sized count distribution, so Explore looks realistic.
2026-06-30 09:59:50 +01:00
LyAhn 0d9229635b refactor(explore): rename misnamed "tag cloud" to "visual clusters"
The visual k-means cluster feature was confusingly named tag_cloud / tagCloud /
TagCloud across the whole stack, while the actual tag list is explore_tags — the
two were trivially easy to mix up (and did cause confusion). Rename the cluster
side to visual_cluster / visualCluster / VisualCluster everywhere: command
get_tag_cloud -> get_visual_clusters (+ lib.rs registration and the invoke
string), VisualClusterEntry, the store fields/actions/tokens, and the mock
backend. Old names are retired rather than reused, so any missed reference fails
loudly instead of silently resolving to the wrong concept.

The tags side keeps its accurate explore_tags naming, and the user-facing
"Tag Cloud" UI label is unchanged.

Also rename the SQLite tag_cloud_cache table -> visual_cluster_cache (the old
table is dropped during schema setup — it is a disposable cache already
invalidated by the clustering version bump) and the TagCloud.tsx component file
-> ExploreView.tsx, since it is the Explore container hosting both the cluster
and tag views.
2026-06-30 09:48:38 +01:00
LyAhn cdb8aa20b9 Merge: faster Explore visual clustering on large libraries
Sampled, parallel k-means with density-aware k-means++ seeding makes first-time
visual clustering on large libraries fast, without a single cluster swallowing
tens of thousands of generic images. Together with the earlier cache-hit
optimization, the Explore tab no longer stalls for several seconds on 80k+
libraries.
2026-06-30 09:10:15 +01:00
LyAhn d2af84d9e8 perf(explore): sampled, parallel visual clustering for large libraries
Computing visual clusters was O(n·k·dim) per Lloyd iteration over the whole
library, single-threaded — several seconds on an 80k-image library on first
view. Find centroids on a deterministic, evenly-strided sample (<=3000
embeddings) and then assign every image to its nearest centroid in one parallel
rayon pass. Libraries at or below the sample cap are unchanged.

Replace the greedy farthest-point seeding (which seeds outliers, leaving the
dense core under-represented on a sample so one centroid absorbed tens of
thousands of generic images) with proper density-aware k-means++ D² seeding,
made deterministic via a small fixed-seed SplitMix64 PRNG. This keeps clusters
balanced on large libraries.

A CLUSTER_CACHE_VERSION is folded into the tag-cloud cache key so existing
caches computed by the old algorithm are invalidated and recomputed. The
clustering timing line and the cache-write failure now go through the `log`
facade (debug/warn) instead of eprintln.
2026-06-30 09:08:48 +01:00
11 changed files with 249 additions and 104 deletions
+5
View File
@@ -70,6 +70,11 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
is now near-instant. The cluster cache is validated from a lightweight
image-ID signature instead of re-reading every embedding, so big libraries no
longer stall for several seconds even when the cached result is reused.
- **Faster first-time visual clustering** — computing clusters for a large
library is now much quicker: centroids are found on a representative sample and
every image is then assigned across all CPU cores, instead of iterating over
the whole library single-threaded. Density-aware seeding keeps the groupings
balanced, so no single cluster swallows a huge share of images.
- **Tag manager search and sort** — the Manage mode tag list now has a live
filter input and a sort dropdown (most-used / least-used / AZ / ZA). The
list is virtualised so libraries with thousands of tags scroll without lag,
+156 -30
View File
@@ -1161,7 +1161,7 @@ pub async fn reset_generated_captions(
}
#[derive(Serialize, Deserialize)]
pub struct TagCloudEntry {
pub struct VisualClusterEntry {
pub count: usize,
pub representative_image_id: i64,
pub thumbnail_path: Option<String>,
@@ -1174,10 +1174,10 @@ pub struct TagCloudEntry {
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
/// calls (including across app restarts) return instantly when the library hasn't changed.
#[tauri::command]
pub async fn get_tag_cloud(
pub async fn get_visual_clusters(
db: State<'_, DbState>,
folder_id: Option<i64>,
) -> Result<Vec<TagCloudEntry>, String> {
) -> Result<Vec<VisualClusterEntry>, String> {
let folder_scope = match folder_id {
Some(id) => format!("folder_{id}"),
None => "all".to_string(),
@@ -1201,6 +1201,7 @@ pub async fn get_tag_cloud(
let mut hasher = Xxh3::new();
hasher.update(&ids_hash.to_le_bytes());
hasher.update(revision.as_bytes());
hasher.update(CLUSTER_CACHE_VERSION.as_bytes());
hasher.digest()
};
(count, hash)
@@ -1213,10 +1214,10 @@ pub async fn get_tag_cloud(
// Try to return a valid SQLite cache before loading any embeddings.
{
let conn = db.get().map_err(|e| e.to_string())?;
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash)
if let Some(json) = db::get_visual_cluster_cache(&conn, &folder_scope, current_hash)
.map_err(|e| e.to_string())?
{
if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) {
if let Ok(entries) = serde_json::from_str::<Vec<VisualClusterEntry>>(&json) {
// Reject cache entries written before image_ids were tracked — they all
// have empty image_ids which causes "No media found" when a cluster is opened.
if entries.iter().all(|e| !e.image_ids.is_empty()) {
@@ -1243,9 +1244,15 @@ pub async fn get_tag_cloud(
.collect();
let k = (n / 20).clamp(5, 30);
let kmeans_start = std::time::Instant::now();
let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40);
log::debug!(
"visual-cluster clustering: {n} embeddings, k={k}, sampled={} → {:.2?}",
n > MAX_KMEANS_SAMPLE,
kmeans_start.elapsed(),
);
let mut entries: Vec<TagCloudEntry> = Vec::new();
let mut entries: Vec<VisualClusterEntry> = Vec::new();
let mut order: Vec<usize> = (0..k).collect();
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
@@ -1278,7 +1285,7 @@ pub async fn get_tag_cloud(
.ok()
.and_then(|img| img.thumbnail_path);
entries.push(TagCloudEntry {
entries.push(VisualClusterEntry {
count,
representative_image_id: best_id,
thumbnail_path,
@@ -1289,8 +1296,8 @@ pub async fn get_tag_cloud(
// Persist to SQLite. The cache is best-effort, but a silent write failure here
// would defeat the whole optimization (every visit would recompute), so log it.
if let Ok(json) = serde_json::to_string(&entries) {
if let Err(e) = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json) {
eprintln!("failed to persist tag-cloud cache for {folder_scope}: {e}");
if let Err(e) = db::set_visual_cluster_cache(&conn, &folder_scope, current_hash, &json) {
log::warn!("failed to persist visual-cluster cache for {folder_scope}: {e}");
}
}
@@ -1846,33 +1853,138 @@ fn normalize(v: &mut [f32]) {
}
}
/// When the library is larger than this, the iterative k-means centroid search
/// runs on a deterministic, evenly-strided sample of this many embeddings rather
/// than the full set; every image is then assigned to its nearest centroid in a
/// single parallel pass. The centroid search is the expensive O(n·k·dim·iters)
/// part, so sampling it keeps the (statistically equivalent) clustering fast on
/// large libraries while the full assignment still places every image.
const MAX_KMEANS_SAMPLE: usize = 3000;
/// Bumped whenever the clustering algorithm changes so persisted visual-cluster caches
/// computed by an older algorithm are invalidated and recomputed on next view.
const CLUSTER_CACHE_VERSION: &str = "sampled-v2";
/// Cosine k-means. For large inputs the iterative centroid search runs on a
/// strided sample (see [`MAX_KMEANS_SAMPLE`]); the final assignment of every
/// point to its nearest centroid always covers the full input, in parallel.
fn kmeans_cosine(
points: &[Vec<f32>],
k: usize,
max_iter: usize,
) -> (Vec<Vec<f32>>, Vec<usize>, Vec<usize>) {
let n = points.len();
let dim = points[0].len();
// Deterministic k-means++ init: spread centroids as far apart as possible
// Deterministic, evenly-distributed sample for the centroid search. For n at
// or below the cap this is just the full set (a clone), so behaviour is
// unchanged on small/medium libraries.
let sample: Vec<Vec<f32>> = if n > MAX_KMEANS_SAMPLE {
let step = n / MAX_KMEANS_SAMPLE;
points
.iter()
.step_by(step)
.take(MAX_KMEANS_SAMPLE)
.cloned()
.collect()
} else {
points.to_vec()
};
let centroids = kmeans_centroids(&sample, k, max_iter);
// Assign every point — not just the sample — to its nearest centroid.
let assignments = assign_to_centroids(points, &centroids);
let mut counts = vec![0usize; k];
for &a in &assignments {
counts[a] += 1;
}
(centroids, counts, assignments)
}
/// Small deterministic PRNG (SplitMix64) so k-means++ seeding is reproducible
/// across runs without pulling in the `rand` crate.
struct DetRng(u64);
impl DetRng {
fn new(seed: u64) -> Self {
DetRng(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform f64 in [0, 1).
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
/// Deterministic k-means++ seeding: the first centroid is the middle point, then
/// each subsequent centroid is drawn from the remaining points with probability
/// proportional to its squared distance to the nearest chosen centroid (D²
/// weighting). For unit-normalized embeddings the squared Euclidean distance is
/// proportional to `1 - cos`, so that is used as the weight. Unlike greedy
/// farthest-point seeding this places proportionally more centroids in dense
/// regions, preventing a single centroid from absorbing the whole dominant mode.
fn kmeans_pp_seed(points: &[Vec<f32>], k: usize) -> Vec<Vec<f32>> {
let n = points.len();
let mut rng = DetRng::new(0x5EED_1234_ABCD_0001);
let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
centroids.push(points[n / 2].clone());
// d2[i] = distance from point i to its nearest chosen centroid (∝ squared
// Euclidean for unit vectors), kept up to date as centroids are added.
let mut d2 = vec![f32::MAX; n];
for _ in 1..k {
let next = points
.iter()
.map(|p| {
let best_sim = centroids
.iter()
.map(|c| dot(p, c))
.fold(f32::NEG_INFINITY, f32::max);
1.0 - best_sim // distance = 1 - cosine_similarity
})
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
centroids.push(points[next].clone());
let last = centroids.last().unwrap();
for (i, p) in points.iter().enumerate() {
let dist = (1.0 - dot(p, last)).max(0.0);
if dist < d2[i] {
d2[i] = dist;
}
}
let total: f64 = d2.iter().map(|&w| w as f64).sum();
if total <= 0.0 {
// Remaining points coincide with chosen centroids; pad deterministically.
centroids.push(points[n / 2].clone());
continue;
}
let mut target = rng.next_f64() * total;
let mut idx = n - 1;
for (i, &w) in d2.iter().enumerate() {
target -= w as f64;
if target <= 0.0 {
idx = i;
break;
}
}
centroids.push(points[idx].clone());
}
centroids
}
/// Runs deterministic k-means++ seeding + Lloyd iterations on `points`, returning
/// the final normalized centroids. Operates only on the slice it is given — the
/// caller passes a sample for large libraries.
fn kmeans_centroids(points: &[Vec<f32>], k: usize, max_iter: usize) -> Vec<Vec<f32>> {
let n = points.len();
let dim = points[0].len();
// Density-aware k-means++ seeding (see kmeans_pp_seed). Crucially NOT greedy
// farthest-point seeding, which picks outliers and — on a sampled large
// library — leaves the dense core under-seeded so one centroid swallows a huge
// share of the (visually generic) images.
let mut centroids = kmeans_pp_seed(points, k);
let mut assignments = vec![0usize; n];
@@ -1914,12 +2026,26 @@ fn kmeans_cosine(
}
}
let mut counts = vec![0usize; k];
for &a in &assignments {
counts[a] += 1;
}
centroids
}
(centroids, counts, assignments)
/// Assigns each point to its nearest centroid (max cosine similarity) in parallel.
/// This is the only step that touches every embedding, so parallelising it is
/// where the multi-core speed-up on large libraries comes from.
fn assign_to_centroids(points: &[Vec<f32>], centroids: &[Vec<f32>]) -> Vec<usize> {
use rayon::prelude::*;
points
.par_iter()
.map(|p| {
centroids
.iter()
.enumerate()
.map(|(j, c)| (j, dot(p, c)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(j, _)| j)
.unwrap_or(0)
})
.collect()
}
#[derive(Serialize)]
+15 -11
View File
@@ -267,12 +267,16 @@ pub fn migrate(conn: &Connection) -> Result<()> {
UNIQUE(image_id, tag)
);
CREATE TABLE IF NOT EXISTS tag_cloud_cache (
CREATE TABLE IF NOT EXISTS visual_cluster_cache (
folder_scope TEXT PRIMARY KEY,
image_ids_hash INTEGER NOT NULL,
entries_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Renamed from the misnamed tag_cloud_cache (it caches visual clusters,
-- not tags). The cache is disposable and was already invalidated by a
-- cluster-algorithm version bump, so the old table is simply dropped.
DROP TABLE IF EXISTS tag_cloud_cache;
CREATE TABLE IF NOT EXISTS duplicate_scan_cache (
folder_scope TEXT PRIMARY KEY,
@@ -407,7 +411,7 @@ fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> {
delete_stmt.execute([id])?;
}
}
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?;
log::info!(
@@ -2799,9 +2803,9 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
)?;
// …then drop the now-duplicate leftovers still under the old name.
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename
// The visual-cluster cache keys on image-id hashes, not tag text, so a rename
// wouldn't invalidate it automatically — clear it.
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?;
Ok(())
}
@@ -2810,7 +2814,7 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?;
Ok(removed)
}
@@ -2983,14 +2987,14 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
})
}
/// Returns cached tag-cloud entries if the stored hash matches `current_hash`.
pub fn get_tag_cloud_cache(
/// Returns cached visual-cluster entries if the stored hash matches `current_hash`.
pub fn get_visual_cluster_cache(
conn: &Connection,
folder_scope: &str,
current_hash: u64,
) -> Result<Option<String>> {
let result: rusqlite::Result<(i64, String)> = conn.query_row(
"SELECT image_ids_hash, entries_json FROM tag_cloud_cache WHERE folder_scope = ?1",
"SELECT image_ids_hash, entries_json FROM visual_cluster_cache WHERE folder_scope = ?1",
params![folder_scope],
|row| Ok((row.get(0)?, row.get(1)?)),
);
@@ -3002,15 +3006,15 @@ pub fn get_tag_cloud_cache(
}
}
/// Upserts the tag-cloud cache for the given scope.
pub fn set_tag_cloud_cache(
/// Upserts the visual-cluster cache for the given scope.
pub fn set_visual_cluster_cache(
conn: &Connection,
folder_scope: &str,
image_ids_hash: u64,
entries_json: &str,
) -> Result<()> {
conn.execute(
"INSERT INTO tag_cloud_cache (folder_scope, image_ids_hash, entries_json, created_at)
"INSERT INTO visual_cluster_cache (folder_scope, image_ids_hash, entries_json, created_at)
VALUES (?1, ?2, ?3, datetime('now'))
ON CONFLICT(folder_scope) DO UPDATE SET
image_ids_hash = excluded.image_ids_hash,
+1 -1
View File
@@ -198,7 +198,7 @@ pub fn run() {
commands::get_worker_states,
commands::get_worker_pauses_persist,
commands::set_worker_pauses_persist,
commands::get_tag_cloud,
commands::get_visual_clusters,
commands::get_explore_tags,
commands::get_related_tags,
commands::get_images_by_ids,
+1 -1
View File
@@ -26,7 +26,7 @@ pub const JOYTAG_MODEL_NAME: &str = "joytag";
// CLIP-style mean/std normalization on [0,1] values (not raw [0,255]), and an
// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants.
const JOYTAG_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_58, 0.275_777_11];
const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
// JoyTag's recommended detection threshold (used as the default; the user's
// tagger_threshold setting still overrides it).
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
+1 -1
View File
@@ -268,7 +268,7 @@ pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
/// image between folders changes it even when the count happens to stay the same.
/// Used (together with the embedding revision, which catches an image being
/// re-embedded in place) as the cheap tag-cloud cache key so a cache hit doesn't
/// re-embedded in place) as the cheap visual-cluster cache key so a cache hit doesn't
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
use xxhash_rust::xxh3::Xxh3;
+2 -2
View File
@@ -5,7 +5,7 @@ import { BackgroundTasks } from "./components/BackgroundTasks";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud";
import { ExploreView } from "./components/ExploreView";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar";
@@ -88,7 +88,7 @@ export default function App() {
) : activeView === "explore" ? (
<>
<BackgroundTasks />
<TagCloud />
<ExploreView />
</>
) : activeView === "duplicates" ? (
<>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ThemedDropdown } from "./ThemedDropdown";
import { Tooltip } from "./Tooltip";
@@ -43,7 +43,7 @@ function seeded(n: number): number {
}
interface PlacedNode {
entry: TagCloudEntry;
entry: VisualClusterEntry;
index: number;
x: number;
y: number;
@@ -57,7 +57,7 @@ interface PlacedNode {
rotateSeed: number;
}
function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] {
function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
const maxCount = Math.max(...entries.map((e) => e.count));
@@ -622,13 +622,13 @@ function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) {
}
// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden
// mounted — not at ExploreView mount time when the container may still be hidden
// behind a loading state.
function ClusterCloud({
entries,
onOpen,
}: {
entries: TagCloudEntry[];
entries: VisualClusterEntry[];
onOpen: (imageIds: number[]) => void;
}) {
const reducedMotion = useReducedMotion();
@@ -968,12 +968,12 @@ function TagManageList({
);
}
export function TagCloud() {
export function ExploreView() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries);
const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading);
const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters);
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
@@ -989,13 +989,13 @@ export function TagCloud() {
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => {
if (exploreMode === "visual") void loadTagCloud();
if (exploreMode === "visual") void loadVisualClusters();
else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
}, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]);
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length;
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
return (
@@ -1070,7 +1070,7 @@ export function TagCloud() {
</div>
) : exploreMode === "visual" ? (
<div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
<ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
</div>
) : manageTags ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
+2 -2
View File
@@ -250,8 +250,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
case "find_similar_images":
case "find_similar_by_region":
return similarImages(payload);
case "get_tag_cloud":
return db.scenario === "empty" ? [] : db.tagCloud;
case "get_visual_clusters":
return db.scenario === "empty" ? [] : db.visualClusters;
case "get_explore_tags":
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180));
case "get_related_tags": {
+17 -7
View File
@@ -9,7 +9,7 @@ import type {
ImageRecord,
ImageTag,
SortOrder,
TagCloudEntry,
VisualClusterEntry,
} from "../store";
import type { MockScenario } from "./mockScenarios";
import { fixtureMediaPath } from "./mockMedia";
@@ -21,7 +21,7 @@ export interface MockDb {
albums: Album[];
albumImageIds: Record<number, number[]>;
tagsByImageId: Record<number, ImageTag[]>;
tagCloud: TagCloudEntry[];
visualClusters: VisualClusterEntry[];
exploreTags: ExploreTagEntry[];
backgroundJobs: FolderJobProgress[];
duplicateGroups: DuplicateGroup[];
@@ -252,12 +252,22 @@ function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgr
}));
}
function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
return tags.slice(0, 10).map((_, index) => {
function makeVisualClusters(images: ImageRecord[], scenario: MockScenario): VisualClusterEntry[] {
if (images.length < 5) return [];
// Mirror the backend's k = (n / 20).clamp(5, 30). Force the full spread for the
// "huge" scenario so Explore shows a realistic field of clusters rather than a
// fixed handful, and size the counts off a large virtual library so the badges
// read like a big collection.
const clusterCount = scenario === "huge" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20)));
const virtualTotal = scenario === "huge" ? 12_000 : images.length;
// Long-tailed sizes (a few large clusters, many smaller), like real k-means output.
const weights = Array.from({ length: clusterCount }, (_, i) => 1 / (i + 1.5));
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
return weights.map((weight, index) => {
const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28);
const representative = group[0] ?? images[index];
const representative = group[0] ?? images[index % images.length];
return {
count: Math.max(group.length, 1),
count: Math.max(1, Math.round((virtualTotal * weight) / weightSum)),
representative_image_id: representative?.id ?? index + 1,
thumbnail_path: representative?.thumbnail_path ?? null,
image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1],
@@ -305,7 +315,7 @@ export function createMockDb(scenario: MockScenario): MockDb {
albums,
albumImageIds,
tagsByImageId: makeTags(images, scenario),
tagCloud: makeTagCloud(images),
visualClusters: makeVisualClusters(images, scenario),
exploreTags: makeExploreTags(images, scenario),
backgroundJobs: makeProgress(folders, scenario),
duplicateGroups: makeDuplicateGroups(images, scenario),
+34 -34
View File
@@ -203,7 +203,7 @@ export interface ImageExif {
gps_lon: number | null;
}
export interface TagCloudEntry {
export interface VisualClusterEntry {
count: number;
representative_image_id: number;
thumbnail_path: string | null;
@@ -376,9 +376,9 @@ interface GalleryState {
activeView: ActiveView;
exploreMode: ExploreMode;
tagManagerOpen: boolean;
tagCloudEntries: TagCloudEntry[];
tagCloudLoading: boolean;
tagCloudFolderId: number | null | undefined; // undefined = never loaded
visualClusterEntries: VisualClusterEntry[];
visualClusterLoading: boolean;
visualClusterFolderId: number | null | undefined; // undefined = never loaded
exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean;
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined
@@ -497,7 +497,7 @@ interface GalleryState {
setExploreMode: (mode: ExploreMode) => void;
setTagManagerOpen: (open: boolean) => void;
openTagManager: () => void;
loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
loadVisualClusters: (options?: { force?: boolean }) => Promise<void>;
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
showVisualCluster: (imageIds: number[]) => Promise<void>;
@@ -633,7 +633,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24;
// similarity, region search). Any new request increments it so a stale response
// from a previous collection type cannot overwrite newer results.
let galleryRequestToken = 0;
let tagCloudRequestToken = 0;
let visualClusterRequestToken = 0;
let exploreTagRequestToken = 0;
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
@@ -878,9 +878,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
activeView: "gallery",
exploreMode: "visual",
tagManagerOpen: false,
tagCloudEntries: [],
tagCloudLoading: false,
tagCloudFolderId: undefined,
visualClusterEntries: [],
visualClusterLoading: false,
visualClusterFolderId: undefined,
exploreTagEntries: [],
exploreTagLoading: false,
exploreTagsFolderId: undefined,
@@ -1010,7 +1010,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await loadFolders();
await loadBackgroundJobProgress();
// Invalidate tag cloud and explore-tags cache since library content changed.
set({ tagCloudFolderId: undefined, tagCloudEntries: [], exploreTagsFolderId: undefined });
set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined });
// Always refresh the gallery: the removed folder's images may be on screen
// (e.g. in All Media), not only when that folder was the active selection.
await loadImages(true);
@@ -1021,7 +1021,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
await invoke("reindex_folder", { folderId });
await loadFolders();
// Invalidate tag cloud cache since embeddings will be regenerated
set({ tagCloudFolderId: undefined, tagCloudEntries: [] });
set({ visualClusterFolderId: undefined, visualClusterEntries: [] });
await loadBackgroundJobProgress();
},
@@ -1085,7 +1085,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return;
}
// Explore reloads itself via TagCloud's useEffect on selectedFolderId.
// Explore reloads itself via ExploreView's useEffect on selectedFolderId.
if (activeView === "explore") return;
void get().loadImages(true);
@@ -1425,33 +1425,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false });
},
loadTagCloud: async (options) => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
loadVisualClusters: async (options) => {
const { selectedFolderId, visualClusterFolderId, visualClusterLoading } = get();
const force = options?.force ?? false;
// Skip if already loaded for this folder and not currently loading
if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
if (!force && !visualClusterLoading && visualClusterFolderId !== undefined && visualClusterFolderId === selectedFolderId) {
return;
}
const requestToken = ++tagCloudRequestToken;
const requestToken = ++visualClusterRequestToken;
// On a real folder switch, drop the previous folder's clusters so the loading
// panel shows instead of lingering stale results. A same-folder refresh keeps
// them to avoid a flicker when the cache returns instantly.
const isFolderSwitch = tagCloudFolderId !== selectedFolderId;
const isFolderSwitch = visualClusterFolderId !== selectedFolderId;
set({
tagCloudLoading: true,
tagCloudFolderId: selectedFolderId,
...(isFolderSwitch ? { tagCloudEntries: [] } : {}),
visualClusterLoading: true,
visualClusterFolderId: selectedFolderId,
...(isFolderSwitch ? { visualClusterEntries: [] } : {}),
});
try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
const entries = await invoke<VisualClusterEntry[]>("get_visual_clusters", {
folderId: selectedFolderId,
});
if (requestToken !== tagCloudRequestToken) return;
set({ tagCloudEntries: entries, tagCloudLoading: false });
if (requestToken !== visualClusterRequestToken) return;
set({ visualClusterEntries: entries, visualClusterLoading: false });
} catch (error) {
if (requestToken !== tagCloudRequestToken) return;
if (requestToken !== visualClusterRequestToken) return;
console.error("Failed to load tag cloud:", error);
set({ tagCloudLoading: false });
set({ visualClusterLoading: false });
}
},
@@ -2411,13 +2411,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
renameTag: async (from, to) => {
await invoke("rename_tag", { params: { from, to } });
// Tag content changed — invalidate the explore-tags and tag-cloud caches.
// Tag content changed — invalidate the explore-tags and visual-cluster caches.
// Keep the current tag list visible while the refresh runs so manager UI
// state such as filtering and sorting is not lost to a loading remount.
set({
exploreTagsFolderId: undefined,
tagCloudFolderId: undefined,
tagCloudEntries: [],
visualClusterFolderId: undefined,
visualClusterEntries: [],
});
const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === from) {
@@ -2435,8 +2435,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
// state such as filtering and sorting is not lost to a loading remount.
set({
exploreTagsFolderId: undefined,
tagCloudFolderId: undefined,
tagCloudEntries: [],
visualClusterFolderId: undefined,
visualClusterEntries: [],
});
const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === tag) {
@@ -2507,14 +2507,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (ids.length === 0 || cleaned.length === 0) return;
await invoke<void>("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } });
// New tags landed — invalidate Explore tag caches.
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
},
bulkRemoveTag: async (tag) => {
const ids = Array.from(get().gallerySelectedIds);
if (ids.length === 0 || !tag.trim()) return;
await invoke<void>("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } });
set({ exploreTagsFolderId: undefined, tagCloudFolderId: undefined, tagCloudEntries: [] });
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
},
bulkDeleteSelected: async () => {
@@ -2532,8 +2532,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
totalImages: Math.max(0, state.totalImages - succeededIds.length),
gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))),
// Deletion changes tag/duplicate/album aggregates.
tagCloudFolderId: undefined,
tagCloudEntries: [],
visualClusterFolderId: undefined,
visualClusterEntries: [],
exploreTagsFolderId: undefined,
}));
// The DB cascade already removed these from album_images; refresh counts/covers.