= {};
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 (
{activeNode && activeAnchor ? (
) : null}
{visibleConnections.map(({ node, related }) => {
const from = activeAnchor;
const to = anchors[node.entry.tag];
if (!from || !to) return null;
const strength = related.shared_count / maxShared;
return (
{!reducedMotion ? (
) : null}
);
})}
{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 (
setButtonRef(node.entry.tag, element)}
className={`explore-tag-word group absolute inline-flex items-center justify-center rounded-full px-2 py-1 text-center transition-[opacity,transform] duration-300 ease-out before:absolute before:inset-x-[-14px] before:inset-y-[-8px] before:rounded-full before:bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.16),rgba(255,255,255,0.06)_46%,rgba(255,255,255,0)_72%)] before:opacity-0 before:blur-md before:transition-opacity before:duration-300 before:content-[''] hover:before:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/25 ${
isActive ? "before:opacity-100" : ""
}`}
style={{
left: node.x - node.w / 2,
top: node.y - node.h / 2,
width: node.w,
minHeight: node.h,
fontSize: node.fontSize,
color: node.ratio > 0.55 ? node.accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)",
opacity,
transform: `translate3d(0, 0, 0) scale(${isActive ? 1.045 : isRelated ? 1.015 : 1})`,
zIndex: isActive ? 30 : isRelated ? 20 : Math.round(node.ratio * 10),
}}
onMouseEnter={() => setActiveTag(node.entry.tag)}
onFocus={() => setActiveTag(node.entry.tag)}
onMouseLeave={() => setActiveTag(null)}
onBlur={() => setActiveTag(null)}
onClick={() => onSearch(node.entry.tag)}
>
{node.entry.tag}
{connectedRelated ? connectedRelated.shared_count.toLocaleString() : node.entry.count.toLocaleString()}
);
})}
);
}
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 (
);
}
// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden
// behind a loading state.
function ClusterCloud({
entries,
onOpen,
}: {
entries: TagCloudEntry[];
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}
/>
) : (
onSearch(entry.tag)}
>
{entry.tag}
)}
{entry.count.toLocaleString()}
{editing ? (
void commitRename()}
disabled={busy || !value.trim()}
>
Save
setEditing(false)}
disabled={busy}
>
Cancel
) : confirming ? (
{ setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy}
>
Delete
setConfirming(false)}
disabled={busy}
>
Cancel
) : (
setEditing(true)}
>
Rename
setConfirming(true)}
>
Delete
)}
);
}
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.
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 TagCloud() {
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 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);
const [manageTags, setManageTags] = useState(false);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => {
if (exploreMode === "visual") void loadTagCloud();
else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, 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 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 ? (
setManageTags((v) => !v)}
>
{manageTags ? "Done" : "Manage"}
) : null}
setExploreMode("visual")}
>
Clusters
setExploreMode("tags")}
>
Tag Cloud
{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 ? (
) : (
)}
);
}