feat: related-tags atlas in Explore view

Hovering a tag in Explore now loads and displays co-occurring tags as a
weighted cloud. New `get_related_tags` SQL self-join (db.rs/commands.rs),
`loadRelatedTags` store action with per-folder keyed cache, and TagCloud
atlas UI with ResizeObserver-driven layout and RAF animation. Explore tag
limit raised to 180; tag cloud auto-refreshes 700ms after new AI tagging
completes.
This commit is contained in:
2026-06-29 13:21:43 +01:00
parent e4a63c8bb0
commit 949382f28c
7 changed files with 608 additions and 104 deletions
+396 -84
View File
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc";
@@ -224,59 +224,361 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
);
}
// Actual tag cloud — word size driven by log-scaled frequency
function TagWord({
entry,
index,
logMin,
logRange,
onSearch,
}: {
interface AtlasNode {
entry: ExploreTagEntry;
index: number;
logMin: number;
logRange: 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<RelatedTagEntry[]>;
}) {
const theme = useGalleryStore((state) => state.theme);
const isLight = theme === "subtle-light";
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px
const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7;
// Faint low-frequency words read fine as subtle white-on-dark, but the same low
// opacity is unreadable on the light theme's cream, so raise the floor there.
const minOpacity = isLight ? 0.6 : 0.4;
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const [activeTag, setActiveTag] = useState<string | null>(null);
const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([]);
const [anchors, setAnchors] = useState<Record<string, TagAnchor>>({});
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<string, TagAnchor> = {};
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 (
<Tooltip
label={`${entry.tag}${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
followCursor
delay={250}
>
<motion.button
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)}
>
<span
className="font-medium leading-none"
style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
>
{entry.tag}
</span>
<span
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: `${accent}22`, color: accent }}
>
{entry.count.toLocaleString()}
</span>
</motion.button>
</Tooltip>
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8">
<svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<defs>
<radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="rgba(255,255,255,0.2)" />
<stop offset="100%" stopColor="rgba(255,255,255,0)" />
</radialGradient>
</defs>
{activeNode && activeAnchor ? (
<circle cx={activeAnchor.x} cy={activeAnchor.y} r={Math.max(activeNode.w, activeNode.h) * 0.62} fill="url(#tag-atlas-glow)" opacity="0.24" />
) : 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 (
<g key={`${activeTag}:${node.entry.tag}`}>
<motion.line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={node.accent}
strokeWidth={0.35 + strength * 0.55}
strokeOpacity={0.05 + strength * 0.12}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={reducedMotion ? undefined : { pathLength: 0, opacity: 0 }}
animate={reducedMotion ? undefined : { pathLength: 1, opacity: 1 }}
transition={{ duration: 0.32, ease: "easeOut" }}
/>
{!reducedMotion ? (
<motion.line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke={node.accent}
strokeWidth={0.65 + strength * 0.5}
strokeOpacity={0.16 + strength * 0.1}
strokeLinecap="round"
pathLength={1}
strokeDasharray="0.08 1"
vectorEffect="non-scaling-stroke"
initial={{ strokeDashoffset: 1.08, opacity: 0 }}
animate={{ strokeDashoffset: [1.08, 0], opacity: [0, 0.65, 0] }}
transition={{
duration: 4.1 + seeded(node.index + 307) * 1.2,
repeat: Infinity,
ease: "easeInOut",
delay: seeded(node.index + 317) * 0.75,
}}
/>
) : null}
</g>
);
})}
</svg>
{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 (
<Tooltip
key={node.entry.tag}
label={`${node.entry.tag}${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
followCursor
delay={250}
>
<button
ref={(element) => 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)}
>
<span className="relative z-10 block w-full truncate text-center font-medium leading-none">{node.entry.tag}</span>
<span
className={`absolute left-1/2 top-full z-10 mt-1 shrink-0 -translate-x-1/2 rounded-full px-1.5 py-0.5 text-[9px] tabular-nums shadow-sm backdrop-blur-sm transition-opacity ${
isActive || isRelated ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
style={{ backgroundColor: `${node.accent}1A`, color: node.accent }}
>
{connectedRelated ? connectedRelated.shared_count.toLocaleString() : node.entry.count.toLocaleString()}
</span>
</button>
</Tooltip>
);
})}
</div>
);
}
@@ -290,6 +592,33 @@ function Spinner() {
);
}
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 (
<div className="flex flex-1 items-center justify-center px-8">
<div className="flex max-w-sm flex-col items-center text-center">
<div className="mb-4 flex items-center justify-center gap-3 text-white/65">
<Spinner />
<span className="text-sm font-medium">{title}</span>
</div>
<p className="text-sm leading-relaxed text-white/25">{subtitle}</p>
<div className="mt-6 h-px w-40 overflow-hidden rounded-full bg-white/[0.06]">
<motion.div
className="h-full w-16 rounded-full bg-white/25"
animate={{ x: [-72, 184] }}
transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
</div>
</div>
);
}
// 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.
@@ -502,6 +831,7 @@ export function TagCloud() {
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);
@@ -515,17 +845,10 @@ export function TagCloud() {
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;
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
return (
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
@@ -541,7 +864,9 @@ export function TagCloud() {
: hasEntries
? exploreMode === "visual"
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
: 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"}
@@ -583,11 +908,8 @@ export function TagCloud() {
</div>
</div>
{loading ? (
<div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25">
<Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div>
{loading && !hasEntries ? (
<ExploreLoadingPanel mode={exploreMode} />
) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
@@ -597,30 +919,20 @@ export function TagCloud() {
</p>
</div>
) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : manageTags ? (
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
/>
) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8">
<div className="flex flex-wrap items-center justify-center gap-x-0.5 gap-y-1 leading-none">
{exploreTagEntries.map((entry, index) => (
<TagWord
key={entry.tag}
entry={entry}
index={index}
logMin={logMin}
logRange={logRange}
onSearch={searchForTag}
/>
))}
</div>
<div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
</div>
) : manageTags ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
/>
</div>
) : (
<TagAtlas entries={exploreTagEntries} onSearch={searchForTag} loadRelatedTags={loadRelatedTags} />
)}
</div>
);