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, VisualClusterEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { ThemedDropdown } from "./ThemedDropdown"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; const ACCENTS = [ "#60a5fa", "#c084fc", "#4ade80", "#fbbf24", "#f472b4", "#2dd4bf", "#fb923c", "#a78bfa", "#34d399", "#f87171", ]; // Darker variants of each accent for the light theme — the bright originals are // tuned for dark cards and wash out on the cream background. const LIGHT_ACCENTS = [ "#2563eb", "#9333ea", "#16a34a", "#d97706", "#db2777", "#0d9488", "#ea580c", "#7c3aed", "#059669", "#dc2626", ]; const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); function seeded(n: number): number { const x = Math.sin(n * 9301 + 49297) * 233280; return x - Math.floor(x); } interface PlacedNode { entry: VisualClusterEntry; index: number; x: number; y: number; w: number; h: number; zIndex: number; accent: string; driftX: number; driftY: number; driftDuration: number; rotateSeed: number; } 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)); const cx = containerW / 2; const cy = containerH / 2; const n = entries.length; const ASPECT = 0.72; const PAD = 18; // Card width scales with image count; the sub-linear exponent (< 1) widens the // gap so the busiest clusters read as clearly larger and more prominent. const rawWidth = (count: number) => { const ratio = Math.max(count / maxCount, 0.05); return 92 + Math.pow(ratio, 0.65) * 158; // ~92–250px before fit scaling }; // Shrink every card uniformly when their padded footprint can't fit the // canvas, so overlap resolution can actually pull them apart instead of // settling into a pile. (0.6 leaves headroom for imperfect packing.) const totalArea = entries.reduce((sum, e) => { const w = rawWidth(e.count); return sum + (w + PAD) * (w * ASPECT + PAD); }, 0); const usableArea = containerW * containerH * 0.6; const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1; const spreadX = containerW * 0.44; const spreadY = containerH * 0.4; // 1. Seed positions on a phyllotaxis spiral, sized by count. const nodes: PlacedNode[] = entries.map((entry, i) => { const w = rawWidth(entry.count) * fit; const h = w * ASPECT; const radialRatio = Math.sqrt((i + 0.5) / n); const angle = i * GOLDEN_ANGLE; return { entry, index: i, x: cx + Math.cos(angle) * radialRatio * spreadX, y: cy + Math.sin(angle) * radialRatio * spreadY, w, h, // Bigger (busier) clusters stack above smaller ones, so they stay // clickable even if a sliver of overlap survives. zIndex: Math.round(w), accent: ACCENTS[i % ACCENTS.length], driftX: (seeded(i + 11) - 0.5) * 18, driftY: (seeded(i + 17) - 0.5) * 14, driftDuration: 8 + seeded(i + 23) * 7, rotateSeed: (seeded(i + 31) - 0.5) * 4, }; }); // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every // pass so edge cards settle in-bounds instead of being shoved out and // re-overlapping there. const marginX = 14; const marginY = 14; for (let iter = 0; iter < 160; iter++) { for (let a = 0; a < nodes.length; a++) { const na = nodes[a]; for (let b = a + 1; b < nodes.length; b++) { const nb = nodes[b]; const dx = nb.x - na.x; const dy = nb.y - na.y; const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx); const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); if (overlapX <= 0 || overlapY <= 0) continue; // Push along the smaller overlap axis (ternary yields ±1 so coincident // cards still separate rather than stalling at a zero push). if (overlapX < overlapY) { const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); nb.x += push; na.x -= push; } else { const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); nb.y += push; na.y -= push; } } } for (const node of nodes) { node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX); node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY); } } return nodes; } function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) { const src = mediaSrc(node.entry.thumbnail_path); const { w, h, accent } = node; const driftTransition = { duration: node.driftDuration, ease: "easeInOut" as const, delay: seeded(node.index + 41) * 1.6, repeat: 1, repeatType: "reverse" as const, }; return ( onOpen(node.entry.image_ids)} title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`} > {src ? ( ) : (
)}
{/* Accent glow on hover */}

Cluster

{node.entry.count.toLocaleString()}

{/* Anchored to the card corner (not in the count's flex row) so a wide count can't push it past the edge on small cards. */} Open ); } interface AtlasNode { entry: ExploreTagEntry; index: number; x: number; y: number; w: number; h: number; fontSize: number; ratio: number; accent: string; driftX: number; driftY: number; } interface TagAnchor { x: number; y: number; } const TAG_ATLAS_MAX_VISIBLE = 132; const TAG_ATLAS_DENSE_THRESHOLD = 120; function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] { if (!entries.length || containerW <= 0 || containerH <= 0) return []; const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1))); const logMin = Math.min(...logs); const logRange = Math.max(...logs) - logMin || 1; const cx = containerW / 2; const cy = containerH * 0.48; const spreadX = containerW * 0.47; const spreadY = containerH * 0.46; const accents = isLight ? LIGHT_ACCENTS : ACCENTS; const nodes = entries.map((entry, index) => { const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange; const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1; const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale; const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2; const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26); const w = Math.min(containerW * maxWidthRatio, textWidth); const h = fontSize * 1.18 + 14; const radialRatio = Math.sqrt((index + 0.5) / entries.length); const angle = index * GOLDEN_ANGLE; return { entry, index, x: cx + Math.cos(angle) * radialRatio * spreadX, y: cy + Math.sin(angle) * radialRatio * spreadY, w, h, fontSize, ratio, accent: accents[index % accents.length], driftX: (seeded(index + 101) - 0.5) * 7, driftY: (seeded(index + 113) - 0.5) * 6, }; }); const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10; const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7; const margin = 28; for (let iter = 0; iter < 140; iter++) { for (let a = 0; a < nodes.length; a++) { const na = nodes[a]; for (let b = a + 1; b < nodes.length; b++) { const nb = nodes[b]; const dx = nb.x - na.x; const dy = nb.y - na.y; const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx); const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy); if (overlapX <= 0 || overlapY <= 0) continue; if (overlapX < overlapY) { const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); nb.x += push; na.x -= push; } else { const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); nb.y += push; na.y -= push; } } } for (const node of nodes) { node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin); node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin); if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) { node.y += (seeded(node.index + iter + 211) - 0.5) * 2; } if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) { node.x += (seeded(node.index + iter + 223) - 0.5) * 2; } } } for (let iter = 0; iter < 5; iter++) { const weighted = nodes.reduce( (acc, node) => { const weight = 1 + Math.pow(node.ratio, 1.35) * 9; return { x: acc.x + node.x * weight, y: acc.y + node.y * weight, weight: acc.weight + weight, }; }, { x: 0, y: 0, weight: 0 }, ); const offsetX = containerW * 0.48 - weighted.x / weighted.weight; const offsetY = containerH * 0.44 - weighted.y / weighted.weight; if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break; for (const node of nodes) { node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin); node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin); } } return nodes; } function TagAtlas({ entries, onSearch, loadRelatedTags, }: { entries: ExploreTagEntry[]; onSearch: (tag: string) => void; loadRelatedTags: (tag: string) => Promise; }) { const theme = useGalleryStore((state) => state.theme); const isLight = theme === "subtle-light"; const reducedMotion = useReducedMotion(); const canvasRef = useRef(null); const buttonRefs = useRef(new Map()); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); const [activeTag, setActiveTag] = useState(null); const [relatedTags, setRelatedTags] = useState([]); const [anchors, setAnchors] = useState>({}); const visibleEntries = useMemo( () => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries), [entries], ); useLayoutEffect(() => { const el = canvasRef.current; if (!el) return; const update = () => { const r = el.getBoundingClientRect(); setCanvasSize({ w: r.width, h: r.height }); }; update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); useEffect(() => { if (!activeTag) { setRelatedTags([]); return; } let cancelled = false; void loadRelatedTags(activeTag).then((entries) => { if (!cancelled) setRelatedTags(entries); }); return () => { cancelled = true; }; }, [activeTag, loadRelatedTags]); const nodes = useMemo( () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight), [visibleEntries, canvasSize.w, canvasSize.h, isLight], ); const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]); const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined; const minOpacity = isLight ? 0.62 : 0.42; const visibleConnections = useMemo( () => activeNode ? relatedTags .map((related) => { const node = nodeByTag.get(related.tag); return node ? { node, related } : null; }) .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null) .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10) : [], [activeNode, nodeByTag, relatedTags, visibleEntries.length], ); const activeAnchor = activeTag ? anchors[activeTag] : undefined; const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count)); const connectedByTag = useMemo( () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])), [visibleConnections], ); const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => { if (element) { buttonRefs.current.set(tag, element); } else { buttonRefs.current.delete(tag); } }, []); const measureAnchors = useCallback(() => { const canvas = canvasRef.current; if (!canvas || !activeTag) { setAnchors({}); return; } const canvasRect = canvas.getBoundingClientRect(); const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]; const nextAnchors: Record = {}; for (const tag of tagsToMeasure) { const element = buttonRefs.current.get(tag); if (!element) continue; const rect = element.getBoundingClientRect(); nextAnchors[tag] = { x: rect.left - canvasRect.left + rect.width / 2, y: rect.top - canvasRect.top + rect.height / 2, }; } setAnchors(nextAnchors); }, [activeTag, visibleConnections]); useLayoutEffect(() => { if (!activeTag) { setAnchors({}); return; } let firstFrame = 0; let secondFrame = 0; const settleTimer = window.setTimeout(measureAnchors, 180); firstFrame = window.requestAnimationFrame(() => { measureAnchors(); secondFrame = window.requestAnimationFrame(measureAnchors); }); return () => { window.cancelAnimationFrame(firstFrame); window.cancelAnimationFrame(secondFrame); window.clearTimeout(settleTimer); }; }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]); return (
{nodes.map((node) => { const isActive = activeTag === node.entry.tag; const connectedRelated = connectedByTag.get(node.entry.tag); const isRelated = connectedRelated !== undefined; const dimmed = activeTag !== null && !isActive && !isRelated; const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity); return ( ); })}
); } function Spinner() { return ( ); } function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) { const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies"; const subtitle = mode === "visual" ? "Grouping similar images into browsable clusters." : "Collecting tags from the current library scope."; return (
{title}

{subtitle}

); } // Separate component so its useLayoutEffect fires when the canvas is actually // mounted — not at ExploreView mount time when the container may still be hidden // behind a loading state. function ClusterCloud({ entries, onOpen, }: { entries: VisualClusterEntry[]; onOpen: (imageIds: number[]) => void; }) { const reducedMotion = useReducedMotion(); const canvasRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); useLayoutEffect(() => { const el = canvasRef.current; if (!el) return; const update = () => { const r = el.getBoundingClientRect(); setCanvasSize({ w: r.width, h: r.height }); }; update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); const nodes = useMemo( () => buildCloud(entries, canvasSize.w, canvasSize.h), [entries, canvasSize.w, canvasSize.h], ); return (
{nodes.map((node) => ( ))}
); } type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ { value: "count_desc", label: "Most used" }, { value: "count_asc", label: "Least used" }, { value: "az", label: "A-Z" }, { value: "za", label: "Z-A" }, ]; // Compact management tile for a single tag. Rename doubles as merge when the new // name already exists, and delete applies across the scoped tag set. function TagManageTile({ entry, onSearch, onRename, onDelete, }: { entry: ExploreTagEntry; onSearch: (tag: string) => void; onRename: (from: string, to: string) => Promise; onDelete: (tag: string) => Promise; }) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(entry.tag); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const inputRef = useRef(null); useEffect(() => { if (editing) { setValue(entry.tag); setTimeout(() => inputRef.current?.select(), 0); } }, [editing, entry.tag]); const commitRename = async () => { const next = value.trim(); if (!next || next === entry.tag) { setEditing(false); return; } setBusy(true); try { await onRename(entry.tag, next); setEditing(false); } finally { setBusy(false); } }; return (
{editing ? ( setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void commitRename(); } if (e.key === "Escape") setEditing(false); }} disabled={busy} /> ) : ( )} {entry.count.toLocaleString()}
{editing ? (
) : confirming ? (
) : (
)}
); } function TagManageList({ entries, onSearch, onRename, onDelete, }: { entries: ExploreTagEntry[]; onSearch: (tag: string) => void; onRename: (from: string, to: string) => Promise; onDelete: (tag: string) => Promise; }) { const scrollRef = useRef(null); const measureRef = useRef(null); const [query, setQuery] = useState(""); const [sort, setSort] = useState("count_desc"); const [columns, setColumns] = useState(3); useLayoutEffect(() => { const el = measureRef.current; if (!el) return; const update = () => { const width = el.getBoundingClientRect().width; setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1); }; update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); const filteredEntries = useMemo(() => { const needle = query.trim().toLowerCase(); const filtered = needle ? entries.filter((entry) => entry.tag.toLowerCase().includes(needle)) : entries; return [...filtered].sort((left, right) => { switch (sort) { case "count_asc": return left.count - right.count || left.tag.localeCompare(right.tag); case "az": return left.tag.localeCompare(right.tag); case "za": return right.tag.localeCompare(left.tag); case "count_desc": default: return right.count - left.count || left.tag.localeCompare(right.tag); } }); }, [entries, query, sort]); const rowCount = Math.ceil(filteredEntries.length / columns); const rowVirtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => scrollRef.current, estimateSize: () => 54, overscan: 7, }); const visibleItems = rowVirtualizer.getVirtualItems(); const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]); return (
{entries.length.toLocaleString()} tags {totalUses.toLocaleString()} uses {query.trim() ? ( {filteredEntries.length.toLocaleString()} matches ) : null}

Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.

setQuery(event.target.value)} placeholder="Filter tags" /> {query ? ( ) : null}
setSort(value as TagManageSort)} options={TAG_MANAGE_SORTS} ariaLabel="Sort managed tags" align="right" />
{filteredEntries.length === 0 ? (
No tags match that filter.
) : (
{visibleItems.map((virtualRow) => { const start = virtualRow.index * columns; const rowEntries = filteredEntries.slice(start, start + columns); return (
{rowEntries.map((entry) => ( ))}
); })}
)}
); } export function ExploreView() { const exploreMode = useGalleryStore((state) => state.exploreMode); const setExploreMode = useGalleryStore((state) => state.setExploreMode); 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); const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const searchForTag = useGalleryStore((state) => state.searchForTag); const renameTag = useGalleryStore((state) => state.renameTag); const deleteTag = useGalleryStore((state) => state.deleteTag); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); // Manage mode lives in the store so it can be opened from elsewhere (Settings). const manageTags = useGalleryStore((state) => state.tagManagerOpen); const setManageTags = useGalleryStore((state) => state.setTagManagerOpen); const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; useEffect(() => { if (exploreMode === "visual") void loadVisualClusters(); else void loadExploreTags(); }, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]); 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 (
{/* Header — `relative z-10` keeps the folder-scope dropdown above the cluster canvas, whose cards use a high z-index of their own. */}

Explore

{loading ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` : manageTags ? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage` : visibleTagCount < entryCount ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` : exploreMode === "visual" ? "No clusters — images need embeddings first" : "No tags — run the AI tagger or add tags manually"}

{exploreMode === "tags" && hasEntries ? ( ) : null}
{loading && !hasEntries ? ( ) : !hasEntries ? (

{exploreMode === "visual" ? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar." : "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}

) : exploreMode === "visual" ? (
) : manageTags ? (
) : ( )}
); }