diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index d69be2d..fe89011 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -1,1096 +1,10 @@ -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 { useEffect } from "react"; +import { useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { Dropdown } from "./menu"; -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)} - > - {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" }, -]; - -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); +}