import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; const ACCENTS = [ "#60a5fa", "#c084fc", "#4ade80", "#fbbf24", "#f472b4", "#2dd4bf", "#fb923c", "#a78bfa", "#34d399", "#f87171", ]; 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: TagCloudEntry; index: number; x: number; y: number; w: number; h: number; accent: string; driftX: number; driftY: number; driftDuration: number; rotateSeed: number; } function buildCloud(entries: TagCloudEntry[], 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; // Spread ellipse shrinks slightly to leave room for card half-widths at the edges const spreadX = containerW * 0.42; const spreadY = containerH * 0.36; const n = entries.length; // 1. Build initial positions using phyllotaxis spiral const nodes: PlacedNode[] = entries.map((entry, i) => { const ratio = Math.max(entry.count / maxCount, 0.08); // Cards scale from 110px to 230px wide; height is 3/4 of width const w = 110 + Math.sqrt(ratio) * 120; const h = w * 0.75; 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, 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. Iterative overlap resolution — no physics, just push apart const PAD = 24; for (let iter = 0; iter < 80; 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 if (overlapX < overlapY) { const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1); nb.x += push; na.x -= push; } else { const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1); nb.y += push; na.y -= push; } } // Pull gently back toward anchor to prevent runaway drift na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05; na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05; } } // 3. Clamp so cards never poke outside the container return nodes.map((node) => ({ ...node, x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16), y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16), })); } function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) { const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null; const { w, h, accent } = node; return ( onOpen(node.entry.image_ids)} title={`Open cluster — ${node.entry.count.toLocaleString()} images`} > {src ? ( ) : (
)}
{/* Accent glow on hover */}

Cluster

{node.entry.count.toLocaleString()}

Open
); } // Actual tag cloud — word size driven by log-scaled frequency function TagWord({ entry, index, logMin, logRange, onSearch, }: { entry: ExploreTagEntry; index: number; logMin: number; logRange: number; onSearch: (tag: string) => void; }) { const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; const fontSize = 11 + ratio * 28; // 11px – 39px const accent = ACCENTS[index % ACCENTS.length]; const tilt = (seeded(index + 5) - 0.5) * 7; return ( onSearch(entry.tag)} title={`${entry.tag} — ${entry.count.toLocaleString()} images`} > 0.55 ? accent : "rgba(255,255,255,0.82)" }} > {entry.tag} {entry.count.toLocaleString()} ); } function Spinner() { 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 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) => ( ))}
); } 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 showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const searchForTag = useGalleryStore((state) => state.searchForTag); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); useEffect(() => { if (exploreMode === "visual") void loadTagCloud(); else void loadExploreTags(); }, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]); const { logMin, logRange } = useMemo(() => { if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 }; const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1))); const lo = Math.min(...logs); const hi = Math.max(...logs); return { logMin: lo, logRange: hi - lo || 1 }; }, [exploreTagEntries]); const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading; const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; return (
{/* Header */}

Explore

{loading ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` : `${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"}

{loading ? (
{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}
) : !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" ? ( ) : ( /* Tag cloud — words sized by log-scaled frequency, wrapped freely */
{exploreTagEntries.map((entry, index) => ( ))}
)}
); }