From 2ce1547844f1233195bd1312c0095369a8753614 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 28 Jun 2026 21:12:26 +0100 Subject: [PATCH] fix: improve Explore cluster layout and light-theme readability --- src/components/TagCloud.tsx | 152 ++++++++++++++++++++++++------------ src/index.css | 18 ++--- 2 files changed, 108 insertions(+), 62 deletions(-) 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 ( 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 ? ( {node.entry.count.toLocaleString()}

Open @@ -203,35 +240,45 @@ function TagWord({ logRange: number; 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 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; + // 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 ( - onSearch(entry.tag)} - title={`${entry.tag} — ${entry.count.toLocaleString()} images`} + - 0.55 ? accent : "rgba(255,255,255,0.82)" }} + onSearch(entry.tag)} > - {entry.tag} - - - {entry.count.toLocaleString()} - - + 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }} + > + {entry.tag} + + + {entry.count.toLocaleString()} + +
+ ); } @@ -278,7 +325,7 @@ function ClusterCloud({ ); return ( -
+
{nodes.map((node) => ( - {/* Header */} -
+ {/* Header — `relative z-10` keeps the folder-scope dropdown above the + cluster canvas, whose cards use a high z-index of their own. */} +

Explore

diff --git a/src/index.css b/src/index.css index 8ba3def..e573b50 100644 --- a/src/index.css +++ b/src/index.css @@ -190,31 +190,29 @@ html[data-theme="subtle-light"] .explore-cluster-card:hover { } 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( to top, - rgb(251 250 246 / 0.9), - rgb(251 250 246 / 0.52) 34%, - rgb(251 250 246 / 0.06) 68%, - transparent + rgb(17 18 22 / 0.82), + rgb(17 18 22 / 0.34) 38%, + transparent 66% ) !important; } 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 { - color: #111827 !important; + color: #ffffff !important; } html[data-theme="subtle-light"] .explore-tag-word:hover { background: #e8e2d6 !important; } -html[data-theme="subtle-light"] .explore-tag-word span:first-child { - color: #374151 !important; -} - html[data-theme="subtle-light"] .explore-spinner { border-color: rgb(17 24 39 / 0.18) !important; border-top-color: rgb(17 24 39 / 0.55) !important;