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:
@@ -180,6 +180,19 @@ pub struct GetExploreTagsParams {
|
|||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GetRelatedTagsParams {
|
||||||
|
pub tag: String,
|
||||||
|
pub folder_id: Option<i64>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RelatedTagEntry {
|
||||||
|
pub tag: String,
|
||||||
|
pub shared_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct SearchTagsAutocompleteParams {
|
pub struct SearchTagsAutocompleteParams {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -1277,7 +1290,28 @@ pub async fn get_explore_tags(
|
|||||||
params: GetExploreTagsParams,
|
params: GetExploreTagsParams,
|
||||||
) -> Result<Vec<ExploreTagEntry>, String> {
|
) -> Result<Vec<ExploreTagEntry>, String> {
|
||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48))
|
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(180))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_related_tags(
|
||||||
|
db: State<'_, DbState>,
|
||||||
|
params: GetRelatedTagsParams,
|
||||||
|
) -> Result<Vec<RelatedTagEntry>, String> {
|
||||||
|
let tag = params.tag.trim();
|
||||||
|
if tag.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
|
db::get_related_tags(&conn, tag, params.folder_id, params.limit.unwrap_or(16))
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(tag, shared_count)| RelatedTagEntry { tag, shared_count })
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2436,6 +2436,34 @@ pub fn get_explore_tags(
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_related_tags(
|
||||||
|
conn: &Connection,
|
||||||
|
tag: &str,
|
||||||
|
folder_id: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<(String, i64)>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT other.tag, COUNT(DISTINCT other.image_id) AS shared_count
|
||||||
|
FROM image_tags base
|
||||||
|
JOIN image_tags other ON other.image_id = base.image_id
|
||||||
|
JOIN images i ON i.id = base.image_id
|
||||||
|
WHERE base.tag = ?1
|
||||||
|
AND other.tag != base.tag
|
||||||
|
AND (?2 IS NULL OR i.folder_id = ?2)
|
||||||
|
GROUP BY other.tag
|
||||||
|
ORDER BY shared_count DESC, other.tag ASC
|
||||||
|
LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![tag, folder_id, limit as i64], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
type FailedEmbeddingRow = (i64, String, String, Option<String>);
|
type FailedEmbeddingRow = (i64, String, String, Option<String>);
|
||||||
|
|
||||||
pub fn get_failed_embedding_images(
|
pub fn get_failed_embedding_images(
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ pub fn run() {
|
|||||||
commands::get_worker_states,
|
commands::get_worker_states,
|
||||||
commands::get_tag_cloud,
|
commands::get_tag_cloud,
|
||||||
commands::get_explore_tags,
|
commands::get_explore_tags,
|
||||||
|
commands::get_related_tags,
|
||||||
commands::get_images_by_ids,
|
commands::get_images_by_ids,
|
||||||
commands::get_failed_embedding_images,
|
commands::get_failed_embedding_images,
|
||||||
commands::get_failed_tagging_images,
|
commands::get_failed_tagging_images,
|
||||||
|
|||||||
+396
-84
@@ -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 { motion, useReducedMotion } from "framer-motion";
|
||||||
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store";
|
||||||
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
import { FolderScopeDropdown } from "./FolderScopeDropdown";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { mediaSrc } from "../lib/mediaSrc";
|
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
|
interface AtlasNode {
|
||||||
function TagWord({
|
|
||||||
entry,
|
|
||||||
index,
|
|
||||||
logMin,
|
|
||||||
logRange,
|
|
||||||
onSearch,
|
|
||||||
}: {
|
|
||||||
entry: ExploreTagEntry;
|
entry: ExploreTagEntry;
|
||||||
index: number;
|
index: number;
|
||||||
logMin: number;
|
x: number;
|
||||||
logRange: 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;
|
onSearch: (tag: string) => void;
|
||||||
|
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
|
||||||
}) {
|
}) {
|
||||||
const theme = useGalleryStore((state) => state.theme);
|
const theme = useGalleryStore((state) => state.theme);
|
||||||
const isLight = theme === "subtle-light";
|
const isLight = theme === "subtle-light";
|
||||||
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
|
const reducedMotion = useReducedMotion();
|
||||||
const fontSize = 11 + ratio * 28; // 11px – 39px
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length];
|
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
const tilt = (seeded(index + 5) - 0.5) * 7;
|
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
|
||||||
// Faint low-frequency words read fine as subtle white-on-dark, but the same low
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||||
// opacity is unreadable on the light theme's cream, so raise the floor there.
|
const [relatedTags, setRelatedTags] = useState<RelatedTagEntry[]>([]);
|
||||||
const minOpacity = isLight ? 0.6 : 0.4;
|
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 (
|
return (
|
||||||
<Tooltip
|
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden px-8 py-8">
|
||||||
label={`${entry.tag} — ${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
|
<svg className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||||
followCursor
|
<defs>
|
||||||
delay={250}
|
<radialGradient id="tag-atlas-glow" cx="50%" cy="50%" r="50%">
|
||||||
>
|
<stop offset="0%" stopColor="rgba(255,255,255,0.2)" />
|
||||||
<motion.button
|
<stop offset="100%" stopColor="rgba(255,255,255,0)" />
|
||||||
initial={{ opacity: 0, scale: 0.6 }}
|
</radialGradient>
|
||||||
animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }}
|
</defs>
|
||||||
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
|
{activeNode && activeAnchor ? (
|
||||||
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
|
<circle cx={activeAnchor.x} cy={activeAnchor.y} r={Math.max(activeNode.w, activeNode.h) * 0.62} fill="url(#tag-atlas-glow)" opacity="0.24" />
|
||||||
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
|
) : null}
|
||||||
style={{ fontSize, rotate: tilt }}
|
{visibleConnections.map(({ node, related }) => {
|
||||||
onClick={() => onSearch(entry.tag)}
|
const from = activeAnchor;
|
||||||
>
|
const to = anchors[node.entry.tag];
|
||||||
<span
|
if (!from || !to) return null;
|
||||||
className="font-medium leading-none"
|
const strength = related.shared_count / maxShared;
|
||||||
style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
|
return (
|
||||||
>
|
<g key={`${activeTag}:${node.entry.tag}`}>
|
||||||
{entry.tag}
|
<motion.line
|
||||||
</span>
|
x1={from.x}
|
||||||
<span
|
y1={from.y}
|
||||||
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
|
x2={to.x}
|
||||||
style={{ backgroundColor: `${accent}22`, color: accent }}
|
y2={to.y}
|
||||||
>
|
stroke={node.accent}
|
||||||
{entry.count.toLocaleString()}
|
strokeWidth={0.35 + strength * 0.55}
|
||||||
</span>
|
strokeOpacity={0.05 + strength * 0.12}
|
||||||
</motion.button>
|
strokeLinecap="round"
|
||||||
</Tooltip>
|
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
|
// Separate component so its useLayoutEffect fires when the canvas is actually
|
||||||
// mounted — not at TagCloud mount time when the container may still be hidden
|
// mounted — not at TagCloud mount time when the container may still be hidden
|
||||||
// behind a loading state.
|
// behind a loading state.
|
||||||
@@ -502,6 +831,7 @@ export function TagCloud() {
|
|||||||
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
|
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
|
||||||
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
|
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
|
||||||
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
|
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
|
||||||
|
const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags);
|
||||||
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
|
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
|
||||||
const searchForTag = useGalleryStore((state) => state.searchForTag);
|
const searchForTag = useGalleryStore((state) => state.searchForTag);
|
||||||
const renameTag = useGalleryStore((state) => state.renameTag);
|
const renameTag = useGalleryStore((state) => state.renameTag);
|
||||||
@@ -515,17 +845,10 @@ export function TagCloud() {
|
|||||||
else void loadExploreTags();
|
else void loadExploreTags();
|
||||||
}, [exploreMode, selectedFolderId, loadTagCloud, 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 loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
|
||||||
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
|
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
|
||||||
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
|
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
|
||||||
|
const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE);
|
||||||
|
|
||||||
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]">
|
||||||
@@ -541,7 +864,9 @@ export function TagCloud() {
|
|||||||
: hasEntries
|
: hasEntries
|
||||||
? exploreMode === "visual"
|
? exploreMode === "visual"
|
||||||
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
|
? `${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"
|
: exploreMode === "visual"
|
||||||
? "No clusters — images need embeddings first"
|
? "No clusters — images need embeddings first"
|
||||||
: "No tags — run the AI tagger or add tags manually"}
|
: "No tags — run the AI tagger or add tags manually"}
|
||||||
@@ -583,11 +908,8 @@ export function TagCloud() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading && !hasEntries ? (
|
||||||
<div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25">
|
<ExploreLoadingPanel mode={exploreMode} />
|
||||||
<Spinner />
|
|
||||||
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
|
|
||||||
</div>
|
|
||||||
) : !hasEntries ? (
|
) : !hasEntries ? (
|
||||||
<div className="flex flex-1 items-center justify-center px-8">
|
<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">
|
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
|
||||||
@@ -597,30 +919,20 @@ export function TagCloud() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : exploreMode === "visual" ? (
|
) : exploreMode === "visual" ? (
|
||||||
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
) : manageTags ? (
|
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+15
-1
@@ -241,7 +241,21 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
case "get_tag_cloud":
|
case "get_tag_cloud":
|
||||||
return db.scenario === "empty" ? [] : db.tagCloud;
|
return db.scenario === "empty" ? [] : db.tagCloud;
|
||||||
case "get_explore_tags":
|
case "get_explore_tags":
|
||||||
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 48));
|
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180));
|
||||||
|
case "get_related_tags": {
|
||||||
|
const relatedCounts = new Map<string, number>();
|
||||||
|
for (const imageTags of Object.values(db.tagsByImageId)) {
|
||||||
|
if (!imageTags.some((tag) => tag.tag === p.tag)) continue;
|
||||||
|
for (const tag of imageTags) {
|
||||||
|
if (tag.tag === p.tag) continue;
|
||||||
|
relatedCounts.set(tag.tag, (relatedCounts.get(tag.tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(relatedCounts.entries())
|
||||||
|
.map(([tag, shared_count]) => ({ tag, shared_count }))
|
||||||
|
.sort((left, right) => right.shared_count - left.shared_count || left.tag.localeCompare(right.tag))
|
||||||
|
.slice(0, Number(p.limit ?? 18));
|
||||||
|
}
|
||||||
case "suggest_image_tags":
|
case "suggest_image_tags":
|
||||||
return ["select", "warm light"];
|
return ["select", "warm light"];
|
||||||
case "rename_tag":
|
case "rename_tag":
|
||||||
|
|||||||
+80
-10
@@ -57,6 +57,68 @@ const tags = [
|
|||||||
"blue hour",
|
"blue hour",
|
||||||
"select",
|
"select",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const hugeTagThemes = [
|
||||||
|
"alpine",
|
||||||
|
"archive",
|
||||||
|
"botanical",
|
||||||
|
"cinematic",
|
||||||
|
"coastal",
|
||||||
|
"editorial",
|
||||||
|
"industrial",
|
||||||
|
"interior",
|
||||||
|
"macro",
|
||||||
|
"night",
|
||||||
|
"portrait",
|
||||||
|
"street",
|
||||||
|
"travel",
|
||||||
|
"urban",
|
||||||
|
"wildlife",
|
||||||
|
"workspace",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hugeTagSubjects = [
|
||||||
|
"glass",
|
||||||
|
"steel",
|
||||||
|
"water",
|
||||||
|
"stone",
|
||||||
|
"mist",
|
||||||
|
"foliage",
|
||||||
|
"market",
|
||||||
|
"signage",
|
||||||
|
"neon",
|
||||||
|
"shadow",
|
||||||
|
"reflection",
|
||||||
|
"texture",
|
||||||
|
"silhouette",
|
||||||
|
"motion",
|
||||||
|
"symmetry",
|
||||||
|
"detail",
|
||||||
|
"warm light",
|
||||||
|
"blue hour",
|
||||||
|
"gold accent",
|
||||||
|
"soft focus",
|
||||||
|
"hard light",
|
||||||
|
"negative space",
|
||||||
|
"foreground",
|
||||||
|
"background",
|
||||||
|
"wide angle",
|
||||||
|
"close crop",
|
||||||
|
"environment",
|
||||||
|
"still life",
|
||||||
|
"natural light",
|
||||||
|
"available light",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hugeTags = [
|
||||||
|
...tags,
|
||||||
|
...Array.from({ length: hugeTagThemes.length * hugeTagSubjects.length }, (_, index) => {
|
||||||
|
const theme = hugeTagThemes[index % hugeTagThemes.length];
|
||||||
|
const subject = hugeTagSubjects[Math.floor(index / hugeTagThemes.length) % hugeTagSubjects.length];
|
||||||
|
return `${theme} ${subject}`;
|
||||||
|
}),
|
||||||
|
...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`),
|
||||||
|
];
|
||||||
const aiRatings: AiRating[] = ["general", "sensitive", "questionable"];
|
const aiRatings: AiRating[] = ["general", "sensitive", "questionable"];
|
||||||
|
|
||||||
function daysAgo(days: number): string {
|
function daysAgo(days: number): string {
|
||||||
@@ -136,17 +198,23 @@ function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
|||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTags(images: ImageRecord[]): Record<number, ImageTag[]> {
|
function tagVocabularyForScenario(scenario: MockScenario): string[] {
|
||||||
|
return scenario === "huge" ? hugeTags : tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTags(images: ImageRecord[], scenario: MockScenario): Record<number, ImageTag[]> {
|
||||||
|
const vocabulary = tagVocabularyForScenario(scenario);
|
||||||
|
const tagCount = scenario === "huge" ? 9 : 3;
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
images.map((image, index) => [
|
images.map((image, index) => [
|
||||||
image.id,
|
image.id,
|
||||||
[0, 1, 2].map((offset) => ({
|
Array.from({ length: tagCount }, (_, offset) => ({
|
||||||
id: image.id * 10 + offset,
|
id: image.id * 10 + offset,
|
||||||
image_id: image.id,
|
image_id: image.id,
|
||||||
tag: tags[(index + offset) % tags.length],
|
tag: vocabulary[(index * (offset + 3) + offset * 29) % vocabulary.length],
|
||||||
source: offset === 0 ? "user" : "ai",
|
source: offset === 0 ? "user" : "ai",
|
||||||
ai_model: offset === 0 ? null : "wd-vit-tagger-v3",
|
ai_model: offset === 0 ? null : "wd-vit-tagger-v3",
|
||||||
confidence: offset === 0 ? null : 0.72 + offset * 0.07,
|
confidence: offset === 0 ? null : Math.min(0.99, 0.72 + offset * 0.03),
|
||||||
created_at: daysAgo(index + offset),
|
created_at: daysAgo(index + offset),
|
||||||
})),
|
})),
|
||||||
]),
|
]),
|
||||||
@@ -197,16 +265,18 @@ function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] {
|
function makeExploreTags(images: ImageRecord[], scenario: MockScenario): ExploreTagEntry[] {
|
||||||
return tags.map((tag, index) => {
|
const vocabulary = tagVocabularyForScenario(scenario);
|
||||||
|
return vocabulary.map((tag, index) => {
|
||||||
const representative = images[index % Math.max(images.length, 1)];
|
const representative = images[index % Math.max(images.length, 1)];
|
||||||
|
const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3;
|
||||||
return {
|
return {
|
||||||
tag,
|
tag,
|
||||||
count: Math.max(3, Math.round(images.length / (index + 3))),
|
count: Math.max(1, Math.round(images.length / divisor)),
|
||||||
representative_image_id: representative?.id ?? index + 1,
|
representative_image_id: representative?.id ?? index + 1,
|
||||||
thumbnail_path: representative?.thumbnail_path ?? null,
|
thumbnail_path: representative?.thumbnail_path ?? null,
|
||||||
};
|
};
|
||||||
});
|
}).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
||||||
@@ -234,9 +304,9 @@ export function createMockDb(scenario: MockScenario): MockDb {
|
|||||||
images,
|
images,
|
||||||
albums,
|
albums,
|
||||||
albumImageIds,
|
albumImageIds,
|
||||||
tagsByImageId: makeTags(images),
|
tagsByImageId: makeTags(images, scenario),
|
||||||
tagCloud: makeTagCloud(images),
|
tagCloud: makeTagCloud(images),
|
||||||
exploreTags: makeExploreTags(images),
|
exploreTags: makeExploreTags(images, scenario),
|
||||||
backgroundJobs: makeProgress(folders, scenario),
|
backgroundJobs: makeProgress(folders, scenario),
|
||||||
duplicateGroups: makeDuplicateGroups(images, scenario),
|
duplicateGroups: makeDuplicateGroups(images, scenario),
|
||||||
duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null,
|
duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null,
|
||||||
|
|||||||
+53
-8
@@ -217,6 +217,11 @@ export interface ExploreTagEntry {
|
|||||||
thumbnail_path: string | null;
|
thumbnail_path: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelatedTagEntry {
|
||||||
|
tag: string;
|
||||||
|
shared_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DuplicateGroup {
|
export interface DuplicateGroup {
|
||||||
file_hash: string;
|
file_hash: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
@@ -376,6 +381,7 @@ interface GalleryState {
|
|||||||
exploreTagEntries: ExploreTagEntry[];
|
exploreTagEntries: ExploreTagEntry[];
|
||||||
exploreTagLoading: boolean;
|
exploreTagLoading: boolean;
|
||||||
exploreTagsFolderId: number | null | undefined;
|
exploreTagsFolderId: number | null | undefined;
|
||||||
|
relatedTagsByKey: Record<string, RelatedTagEntry[]>;
|
||||||
indexingProgress: Record<number, IndexProgress>;
|
indexingProgress: Record<number, IndexProgress>;
|
||||||
mediaJobProgress: Record<number, FolderJobProgress>;
|
mediaJobProgress: Record<number, FolderJobProgress>;
|
||||||
cacheDir: string;
|
cacheDir: string;
|
||||||
@@ -480,8 +486,9 @@ interface GalleryState {
|
|||||||
closeImage: () => void;
|
closeImage: () => void;
|
||||||
setView: (view: ActiveView) => void;
|
setView: (view: ActiveView) => void;
|
||||||
setExploreMode: (mode: ExploreMode) => void;
|
setExploreMode: (mode: ExploreMode) => void;
|
||||||
loadTagCloud: () => Promise<void>;
|
loadTagCloud: (options?: { force?: boolean }) => Promise<void>;
|
||||||
loadExploreTags: () => Promise<void>;
|
loadExploreTags: (options?: { force?: boolean }) => Promise<void>;
|
||||||
|
loadRelatedTags: (tag: string) => Promise<RelatedTagEntry[]>;
|
||||||
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
showVisualCluster: (imageIds: number[]) => Promise<void>;
|
||||||
searchForTag: (tag: string) => void;
|
searchForTag: (tag: string) => void;
|
||||||
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
|
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
|
||||||
@@ -615,6 +622,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24;
|
|||||||
let galleryRequestToken = 0;
|
let galleryRequestToken = 0;
|
||||||
let tagCloudRequestToken = 0;
|
let tagCloudRequestToken = 0;
|
||||||
let exploreTagRequestToken = 0;
|
let exploreTagRequestToken = 0;
|
||||||
|
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function initialAiCaptionsEnabled(): boolean {
|
function initialAiCaptionsEnabled(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
@@ -862,6 +870,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
exploreTagEntries: [],
|
exploreTagEntries: [],
|
||||||
exploreTagLoading: false,
|
exploreTagLoading: false,
|
||||||
exploreTagsFolderId: undefined,
|
exploreTagsFolderId: undefined,
|
||||||
|
relatedTagsByKey: {},
|
||||||
indexingProgress: {},
|
indexingProgress: {},
|
||||||
mediaJobProgress: {},
|
mediaJobProgress: {},
|
||||||
cacheDir: "",
|
cacheDir: "",
|
||||||
@@ -1388,10 +1397,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
setExploreMode: (exploreMode) => set({ exploreMode }),
|
setExploreMode: (exploreMode) => set({ exploreMode }),
|
||||||
|
|
||||||
loadTagCloud: async () => {
|
loadTagCloud: async (options) => {
|
||||||
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
|
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
|
||||||
|
const force = options?.force ?? false;
|
||||||
// Skip if already loaded for this folder and not currently loading
|
// Skip if already loaded for this folder and not currently loading
|
||||||
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
|
if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestToken = ++tagCloudRequestToken;
|
const requestToken = ++tagCloudRequestToken;
|
||||||
@@ -1409,19 +1419,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadExploreTags: async () => {
|
loadExploreTags: async (options) => {
|
||||||
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
||||||
if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
const force = options?.force ?? false;
|
||||||
|
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestToken = ++exploreTagRequestToken;
|
const requestToken = ++exploreTagRequestToken;
|
||||||
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
|
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
|
||||||
try {
|
try {
|
||||||
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
|
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
|
||||||
params: { folder_id: selectedFolderId, limit: 48 },
|
params: { folder_id: selectedFolderId, limit: 180 },
|
||||||
});
|
});
|
||||||
if (requestToken !== exploreTagRequestToken) return;
|
if (requestToken !== exploreTagRequestToken) return;
|
||||||
set({ exploreTagEntries: entries, exploreTagLoading: false });
|
set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestToken !== exploreTagRequestToken) return;
|
if (requestToken !== exploreTagRequestToken) return;
|
||||||
console.error("Failed to load explore tags:", error);
|
console.error("Failed to load explore tags:", error);
|
||||||
@@ -1888,6 +1899,28 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
void invoke("set_notifications_paused", { paused }).catch(() => {});
|
void invoke("set_notifications_paused", { paused }).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadRelatedTags: async (tag) => {
|
||||||
|
const trimmed = tag.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
const { selectedFolderId, relatedTagsByKey } = get();
|
||||||
|
const key = `${selectedFolderId ?? "all"}:${trimmed}`;
|
||||||
|
if (relatedTagsByKey[key]) {
|
||||||
|
return relatedTagsByKey[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await invoke<RelatedTagEntry[]>("get_related_tags", {
|
||||||
|
params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 },
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
relatedTagsByKey: {
|
||||||
|
...state.relatedTagsByKey,
|
||||||
|
[key]: entries,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
window.localStorage.setItem(THEME_KEY, theme);
|
window.localStorage.setItem(THEME_KEY, theme);
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
@@ -2887,6 +2920,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
||||||
const batch = event.payload;
|
const batch = event.payload;
|
||||||
|
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
|
||||||
|
if (taggingUpdated) {
|
||||||
|
set({ exploreTagsFolderId: undefined });
|
||||||
|
if (get().activeView === "explore") {
|
||||||
|
if (!exploreTagRefreshTimer) {
|
||||||
|
exploreTagRefreshTimer = setTimeout(() => {
|
||||||
|
exploreTagRefreshTimer = null;
|
||||||
|
void get().loadExploreTags({ force: true });
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const selectedImageUpdate =
|
const selectedImageUpdate =
|
||||||
|
|||||||
Reference in New Issue
Block a user