= {};
- 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" },
-];
-
-function AiSourceGlyph({ className = "" }: { className?: string }) {
- return (
-
- );
-}
-
-function RenameGlyph({ className = "" }: { className?: string }) {
- return (
-
- );
-}
-
-function DeleteGlyph({ className = "" }: { className?: string }) {
- return (
-
- );
-}
-
-// 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);
- }
- };
-
- const sourceLabel = entry.has_ai_source
- ? entry.has_user_source
- ? "Used by AI tags and user tags"
- : "AI-generated tag"
- : "User tag";
-
- 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.has_ai_source ? (
-
-
-
- {entry.count.toLocaleString()}
-
-
- ) : (
-
- {entry.count.toLocaleString()}
-
- )}
-
-
-
- {editing ? (
-
-
-
-
- ) : confirming ? (
-
-
-
-
- ) : (
-
-
-
-
-
-
-
-
- )}
-
- );
-}
-
-function TagManageList({
- entries,
- onSearch,
- onRename,
- onDelete,
- onResetAiTags,
- scopeLabel,
-}: {
- entries: ExploreTagEntry[];
- onSearch: (tag: string) => void;
- onRename: (from: string, to: string) => Promise;
- onDelete: (tag: string) => Promise;
- onResetAiTags: () => Promise;
- scopeLabel: string;
-}) {
- const scrollRef = useRef(null);
- const measureRef = useRef(null);
- const [query, setQuery] = useState("");
- const [sort, setSort] = useState("count_desc");
- const [columns, setColumns] = useState(3);
- const [resetConfirming, setResetConfirming] = useState(false);
- const [resetting, setResetting] = useState(false);
- const [resetStatus, setResetStatus] = useState(null);
-
- 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]);
-
- const runResetAiTags = async () => {
- if (!resetConfirming) {
- setResetConfirming(true);
- setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`);
- return;
- }
-
- setResetting(true);
- setResetStatus(null);
- try {
- const count = await onResetAiTags();
- setResetStatus(
- count === 0
- ? "No AI tag data found for this scope."
- : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
- );
- } catch (error) {
- setResetStatus(String(error));
- } finally {
- setResetting(false);
- setResetConfirming(false);
- }
- };
-
- 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.
-
- {resetStatus ?
{resetStatus}
: null}
-
-
-
-
- {resetConfirming ? (
-
- ) : null}
-
-
setQuery(event.target.value)}
- placeholder="Filter tags"
- />
- {query ? (
-
-
-
-
-
- ) : null}
-
-
-
-
-
-
-
-
- {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) => (
-
- ))}
-
- );
- })}
-
- )}
-
-
-
- );
-}
+import { ClusterCloud } from "./explore/ClusterCloud";
+import { ExploreLoadingPanel } from "./explore/ExploreLoadingPanel";
+import { TagAtlas, TAG_ATLAS_MAX_VISIBLE } from "./explore/TagAtlas";
+import { TagManageList } from "./explore/TagManageList";
export function ExploreView() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
diff --git a/src/components/explore/ClusterCloud.tsx b/src/components/explore/ClusterCloud.tsx
new file mode 100644
index 0000000..2fff6f0
--- /dev/null
+++ b/src/components/explore/ClusterCloud.tsx
@@ -0,0 +1,238 @@
+import { useLayoutEffect, useMemo, useRef, useState } from "react";
+import { motion, useReducedMotion } from "framer-motion";
+import type { VisualClusterEntry } from "../../store";
+import { mediaSrc } from "../../lib/mediaSrc";
+import { Tooltip } from "../Tooltip";
+import { ACCENTS, GOLDEN_ANGLE, seeded } from "./layout";
+
+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)}
+ >
+ {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
+
+
+
+ );
+}
+
+// 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.
+export 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) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/explore/ExploreLoadingPanel.tsx b/src/components/explore/ExploreLoadingPanel.tsx
new file mode 100644
index 0000000..f33b2b3
--- /dev/null
+++ b/src/components/explore/ExploreLoadingPanel.tsx
@@ -0,0 +1,41 @@
+import { motion } from "framer-motion";
+import type { ExploreMode } from "../../store";
+
+function Spinner() {
+ return (
+
+ );
+}
+
+export 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}
+
+
+
+
+
+ );
+}
+
+
diff --git a/src/components/explore/TagAtlas.tsx b/src/components/explore/TagAtlas.tsx
new file mode 100644
index 0000000..62d196f
--- /dev/null
+++ b/src/components/explore/TagAtlas.tsx
@@ -0,0 +1,366 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { motion, useReducedMotion } from "framer-motion";
+import type { ExploreTagEntry, RelatedTagEntry } from "../../store";
+import { useGalleryStore } from "../../store";
+import { Tooltip } from "../Tooltip";
+import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from "./layout";
+
+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;
+}
+
+export 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;
+}
+
+export 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 (
+
+
+
+ );
+ })}
+
+ );
+}
+
+
diff --git a/src/components/explore/TagManageList.tsx b/src/components/explore/TagManageList.tsx
new file mode 100644
index 0000000..05343ea
--- /dev/null
+++ b/src/components/explore/TagManageList.tsx
@@ -0,0 +1,430 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import type { ExploreTagEntry } from "../../store";
+import { Dropdown } from "../menu";
+import { Tooltip } from "../Tooltip";
+
+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" },
+];
+
+function AiSourceGlyph({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+function RenameGlyph({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+function DeleteGlyph({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+// 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);
+ }
+ };
+
+ const sourceLabel = entry.has_ai_source
+ ? entry.has_user_source
+ ? "Used by AI tags and user tags"
+ : "AI-generated tag"
+ : "User tag";
+
+ 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.has_ai_source ? (
+
+
+
+ {entry.count.toLocaleString()}
+
+
+ ) : (
+
+ {entry.count.toLocaleString()}
+
+ )}
+
+
+
+ {editing ? (
+
+
+
+
+ ) : confirming ? (
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export function TagManageList({
+ entries,
+ onSearch,
+ onRename,
+ onDelete,
+ onResetAiTags,
+ scopeLabel,
+}: {
+ entries: ExploreTagEntry[];
+ onSearch: (tag: string) => void;
+ onRename: (from: string, to: string) => Promise;
+ onDelete: (tag: string) => Promise;
+ onResetAiTags: () => Promise;
+ scopeLabel: string;
+}) {
+ const scrollRef = useRef(null);
+ const measureRef = useRef(null);
+ const [query, setQuery] = useState("");
+ const [sort, setSort] = useState("count_desc");
+ const [columns, setColumns] = useState(3);
+ const [resetConfirming, setResetConfirming] = useState(false);
+ const [resetting, setResetting] = useState(false);
+ const [resetStatus, setResetStatus] = useState(null);
+
+ 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]);
+
+ const runResetAiTags = async () => {
+ if (!resetConfirming) {
+ setResetConfirming(true);
+ setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`);
+ return;
+ }
+
+ setResetting(true);
+ setResetStatus(null);
+ try {
+ const count = await onResetAiTags();
+ setResetStatus(
+ count === 0
+ ? "No AI tag data found for this scope."
+ : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
+ );
+ } catch (error) {
+ setResetStatus(String(error));
+ } finally {
+ setResetting(false);
+ setResetConfirming(false);
+ }
+ };
+
+ 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.
+
+ {resetStatus ?
{resetStatus}
: null}
+
+
+
+
+ {resetConfirming ? (
+
+ ) : null}
+
+
setQuery(event.target.value)}
+ placeholder="Filter tags"
+ />
+ {query ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {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) => (
+
+ ))}
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+
diff --git a/src/components/explore/layout.ts b/src/components/explore/layout.ts
new file mode 100644
index 0000000..913a743
--- /dev/null
+++ b/src/components/explore/layout.ts
@@ -0,0 +1,34 @@
+export 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.
+export const LIGHT_ACCENTS = [
+ "#2563eb",
+ "#9333ea",
+ "#16a34a",
+ "#d97706",
+ "#db2777",
+ "#0d9488",
+ "#ea580c",
+ "#7c3aed",
+ "#059669",
+ "#dc2626",
+];
+
+export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
+
+export function seeded(n: number): number {
+ const x = Math.sin(n * 9301 + 49297) * 233280;
+ return x - Math.floor(x);
+}