fix: improve Explore cluster layout and light-theme readability

This commit is contained in:
2026-06-28 21:12:26 +01:00
parent 623aabbb51
commit 2ce1547844
2 changed files with 108 additions and 62 deletions
+82 -34
View File
@@ -3,6 +3,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { Tooltip } from "./Tooltip";
const ACCENTS = [ const ACCENTS = [
"#60a5fa", "#60a5fa",
@@ -17,6 +18,21 @@ const ACCENTS = [
"#f87171", "#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)); const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
function seeded(n: number): number { function seeded(n: number): number {
@@ -31,6 +47,7 @@ interface PlacedNode {
y: number; y: number;
w: number; w: number;
h: number; h: number;
zIndex: number;
accent: string; accent: string;
driftX: number; driftX: number;
driftY: 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 maxCount = Math.max(...entries.map((e) => e.count));
const cx = containerW / 2; const cx = containerW / 2;
const cy = containerH / 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 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; // ~92250px 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 nodes: PlacedNode[] = entries.map((entry, i) => {
const ratio = Math.max(entry.count / maxCount, 0.08); const w = rawWidth(entry.count) * fit;
// Cards scale from 110px to 230px wide; height is 3/4 of width const h = w * ASPECT;
const w = 110 + Math.sqrt(ratio) * 120;
const h = w * 0.75;
const radialRatio = Math.sqrt((i + 0.5) / n); const radialRatio = Math.sqrt((i + 0.5) / n);
const angle = i * GOLDEN_ANGLE; const angle = i * GOLDEN_ANGLE;
@@ -65,6 +99,9 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
y: cy + Math.sin(angle) * radialRatio * spreadY, y: cy + Math.sin(angle) * radialRatio * spreadY,
w, w,
h, 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], accent: ACCENTS[i % ACCENTS.length],
driftX: (seeded(i + 11) - 0.5) * 18, driftX: (seeded(i + 11) - 0.5) * 18,
driftY: (seeded(i + 17) - 0.5) * 14, 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 // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
const PAD = 24; // pass so edge cards settle in-bounds instead of being shoved out and
for (let iter = 0; iter < 80; iter++) { // re-overlapping there.
const marginX = 14;
const marginY = 14;
for (let iter = 0; iter < 160; iter++) {
for (let a = 0; a < nodes.length; a++) { for (let a = 0; a < nodes.length; a++) {
const na = nodes[a]; const na = nodes[a];
for (let b = a + 1; b < nodes.length; b++) { 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 overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue; 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) { if (overlapX < overlapY) {
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1); const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
nb.x += push; nb.x += push;
na.x -= push; na.x -= push;
} else { } else {
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1); const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
nb.y += push; nb.y += push;
na.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; for (const node of nodes) {
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05; 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;
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, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) { 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 ( return (
<motion.button <motion.button
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }} style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }} initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
animate={ animate={
animated animated
@@ -148,9 +185,9 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
} }
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } } : { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
} }
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }} whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)} onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} images`} title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
> >
{src ? ( {src ? (
<img <img
@@ -178,7 +215,7 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p> <p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div> </div>
<span <span
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100" className="explore-cluster-open rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }} style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
> >
Open Open
@@ -203,25 +240,34 @@ function TagWord({
logRange: number; logRange: number;
onSearch: (tag: string) => void; onSearch: (tag: string) => void;
}) { }) {
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 ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px const fontSize = 11 + ratio * 28; // 11px 39px
const accent = ACCENTS[index % ACCENTS.length]; const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7; 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;
return ( return (
<Tooltip
label={`${entry.tag}${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
followCursor
delay={250}
>
<motion.button <motion.button
initial={{ opacity: 0, scale: 0.6 }} initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }} animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }} transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }} 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]" 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 }} style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)} onClick={() => onSearch(entry.tag)}
title={`${entry.tag}${entry.count.toLocaleString()} images`}
> >
<span <span
className="font-medium leading-none" className="font-medium leading-none"
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }} style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
> >
{entry.tag} {entry.tag}
</span> </span>
@@ -232,6 +278,7 @@ function TagWord({
{entry.count.toLocaleString()} {entry.count.toLocaleString()}
</span> </span>
</motion.button> </motion.button>
</Tooltip>
); );
} }
@@ -278,7 +325,7 @@ function ClusterCloud({
); );
return ( return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden"> <div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden">
<div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" /> <div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => ( {nodes.map((node) => (
<CloudCard <CloudCard
@@ -484,8 +531,9 @@ export function TagCloud() {
return ( 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]"> <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]">
{/* Header */} {/* Header — `relative z-10` keeps the folder-scope dropdown above the
<div className="explore-header shrink-0 border-b border-white/[0.05] px-6 py-4"> cluster canvas, whose cards use a high z-index of their own. */}
<div className="explore-header relative z-10 shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2> <h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
+8 -10
View File
@@ -190,31 +190,29 @@ html[data-theme="subtle-light"] .explore-cluster-card:hover {
} }
html[data-theme="subtle-light"] .explore-cluster-overlay { html[data-theme="subtle-light"] .explore-cluster-overlay {
/* A cream wash over the photo read as a faded, foggy haze. Use a gentle dark
scrim instead — the same natural "photo caption" vignette the dark theme
uses — with light text on top (label/count rules below). */
background: linear-gradient( background: linear-gradient(
to top, to top,
rgb(251 250 246 / 0.9), rgb(17 18 22 / 0.82),
rgb(251 250 246 / 0.52) 34%, rgb(17 18 22 / 0.34) 38%,
rgb(251 250 246 / 0.06) 68%, transparent 66%
transparent
) !important; ) !important;
} }
html[data-theme="subtle-light"] .explore-cluster-label { html[data-theme="subtle-light"] .explore-cluster-label {
color: #6b6257 !important; color: rgb(255 255 255 / 0.62) !important;
} }
html[data-theme="subtle-light"] .explore-cluster-count { html[data-theme="subtle-light"] .explore-cluster-count {
color: #111827 !important; color: #ffffff !important;
} }
html[data-theme="subtle-light"] .explore-tag-word:hover { html[data-theme="subtle-light"] .explore-tag-word:hover {
background: #e8e2d6 !important; background: #e8e2d6 !important;
} }
html[data-theme="subtle-light"] .explore-tag-word span:first-child {
color: #374151 !important;
}
html[data-theme="subtle-light"] .explore-spinner { html[data-theme="subtle-light"] .explore-spinner {
border-color: rgb(17 24 39 / 0.18) !important; border-color: rgb(17 24 39 / 0.18) !important;
border-top-color: rgb(17 24 39 / 0.55) !important; border-top-color: rgb(17 24 39 / 0.55) !important;