feat: related-tags atlas in Explore view

Hovering a tag in Explore now loads and displays co-occurring tags as a
weighted cloud. New `get_related_tags` SQL self-join (db.rs/commands.rs),
`loadRelatedTags` store action with per-folder keyed cache, and TagCloud
atlas UI with ResizeObserver-driven layout and RAF animation. Explore tag
limit raised to 180; tag cloud auto-refreshes 700ms after new AI tagging
completes.
This commit is contained in:
2026-06-29 13:21:43 +01:00
parent e4a63c8bb0
commit 949382f28c
7 changed files with 608 additions and 104 deletions
+53 -8
View File
@@ -217,6 +217,11 @@ export interface ExploreTagEntry {
thumbnail_path: string | null;
}
export interface RelatedTagEntry {
tag: string;
shared_count: number;
}
export interface DuplicateGroup {
file_hash: string;
file_size: number;
@@ -376,6 +381,7 @@ interface GalleryState {
exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean;
exploreTagsFolderId: number | null | undefined;
relatedTagsByKey: Record<string, RelatedTagEntry[]>;
indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string;
@@ -480,8 +486,9 @@ interface GalleryState {
closeImage: () => void;
setView: (view: ActiveView) => void;
setExploreMode: (mode: ExploreMode) => void;
loadTagCloud: () => Promise<void>;
loadExploreTags: () => Promise<void>;
loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
@@ -615,6 +622,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24;
let galleryRequestToken = 0;
let tagCloudRequestToken = 0;
let exploreTagRequestToken = 0;
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false;
@@ -862,6 +870,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
exploreTagEntries: [],
exploreTagLoading: false,
exploreTagsFolderId: undefined,
relatedTagsByKey: {},
indexingProgress: {},
mediaJobProgress: {},
cacheDir: "",
@@ -1388,10 +1397,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setExploreMode: (exploreMode) => set({ exploreMode }),
loadTagCloud: async () => {
loadTagCloud: async (options) => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
const force = options?.force ?? false;
// Skip if already loaded for this folder and not currently loading
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
return;
}
const requestToken = ++tagCloudRequestToken;
@@ -1409,19 +1419,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
},
loadExploreTags: async () => {
loadExploreTags: async (options) => {
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
const force = options?.force ?? false;
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
return;
}
const requestToken = ++exploreTagRequestToken;
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
try {
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
params: { folder_id: selectedFolderId, limit: 48 },
params: { folder_id: selectedFolderId, limit: 180 },
});
if (requestToken !== exploreTagRequestToken) return;
set({ exploreTagEntries: entries, exploreTagLoading: false });
set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} });
} catch (error) {
if (requestToken !== exploreTagRequestToken) return;
console.error("Failed to load explore tags:", error);
@@ -1888,6 +1899,28 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void invoke("set_notifications_paused", { paused }).catch(() => {});
},
loadRelatedTags: async (tag) => {
const trimmed = tag.trim();
if (!trimmed) return [];
const { selectedFolderId, relatedTagsByKey } = get();
const key = `${selectedFolderId ?? "all"}:${trimmed}`;
if (relatedTagsByKey[key]) {
return relatedTagsByKey[key];
}
const entries = await invoke<RelatedTagEntry[]>("get_related_tags", {
params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 },
});
set((state) => ({
relatedTagsByKey: {
...state.relatedTagsByKey,
[key]: entries,
},
}));
return entries;
},
setTheme: (theme) => {
window.localStorage.setItem(THEME_KEY, theme);
document.documentElement.dataset.theme = theme;
@@ -2887,6 +2920,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
const batch = event.payload;
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
if (taggingUpdated) {
set({ exploreTagsFolderId: undefined });
if (get().activeView === "explore") {
if (!exploreTagRefreshTimer) {
exploreTagRefreshTimer = setTimeout(() => {
exploreTagRefreshTimer = null;
void get().loadExploreTags({ force: true });
}, 700);
}
}
}
set((state) => {
const selectedImageUpdate =