diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx
index e31b88a..4745bf2 100644
--- a/src/components/TagCloud.tsx
+++ b/src/components/TagCloud.tsx
@@ -3,6 +3,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
+import { Tooltip } from "./Tooltip";
const ACCENTS = [
"#60a5fa",
@@ -17,6 +18,21 @@ const ACCENTS = [
"#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 {
@@ -31,6 +47,7 @@ interface PlacedNode {
y: number;
w: number;
h: number;
+ zIndex: number;
accent: string;
driftX: number;
driftY: number;
@@ -44,17 +61,34 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
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;
+ const ASPECT = 0.72;
+ const PAD = 18;
- // 1. Build initial positions using phyllotaxis spiral
+ // 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 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 w = rawWidth(entry.count) * fit;
+ const h = w * ASPECT;
const radialRatio = Math.sqrt((i + 0.5) / n);
const angle = i * GOLDEN_ANGLE;
@@ -65,6 +99,9 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
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,
@@ -73,9 +110,12 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
};
});
- // 2. Iterative overlap resolution — no physics, just push apart
- const PAD = 24;
- for (let iter = 0; iter < 80; iter++) {
+ // 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++) {
@@ -85,29 +125,26 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
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
+ // 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 * 0.5 * (dx >= 0 ? 1 : -1);
+ const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
} else {
- const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
+ const push = (overlapY / 2) * (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;
+ }
+ 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);
}
}
- // 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),
- }));
+ return nodes;
}
function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
@@ -124,7 +161,7 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
return (
{node.entry.count.toLocaleString()}