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:
+15
-1
@@ -241,7 +241,21 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
||||
case "get_tag_cloud":
|
||||
return db.scenario === "empty" ? [] : db.tagCloud;
|
||||
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":
|
||||
return ["select", "warm light"];
|
||||
case "rename_tag":
|
||||
|
||||
+80
-10
@@ -57,6 +57,68 @@ const tags = [
|
||||
"blue hour",
|
||||
"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"];
|
||||
|
||||
function daysAgo(days: number): string {
|
||||
@@ -136,17 +198,23 @@ function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
||||
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(
|
||||
images.map((image, index) => [
|
||||
image.id,
|
||||
[0, 1, 2].map((offset) => ({
|
||||
Array.from({ length: tagCount }, (_, offset) => ({
|
||||
id: image.id * 10 + offset,
|
||||
image_id: image.id,
|
||||
tag: tags[(index + offset) % tags.length],
|
||||
tag: vocabulary[(index * (offset + 3) + offset * 29) % vocabulary.length],
|
||||
source: offset === 0 ? "user" : "ai",
|
||||
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),
|
||||
})),
|
||||
]),
|
||||
@@ -197,16 +265,18 @@ function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
|
||||
});
|
||||
}
|
||||
|
||||
function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] {
|
||||
return tags.map((tag, index) => {
|
||||
function makeExploreTags(images: ImageRecord[], scenario: MockScenario): ExploreTagEntry[] {
|
||||
const vocabulary = tagVocabularyForScenario(scenario);
|
||||
return vocabulary.map((tag, index) => {
|
||||
const representative = images[index % Math.max(images.length, 1)];
|
||||
const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3;
|
||||
return {
|
||||
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,
|
||||
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[] {
|
||||
@@ -234,9 +304,9 @@ export function createMockDb(scenario: MockScenario): MockDb {
|
||||
images,
|
||||
albums,
|
||||
albumImageIds,
|
||||
tagsByImageId: makeTags(images),
|
||||
tagsByImageId: makeTags(images, scenario),
|
||||
tagCloud: makeTagCloud(images),
|
||||
exploreTags: makeExploreTags(images),
|
||||
exploreTags: makeExploreTags(images, scenario),
|
||||
backgroundJobs: makeProgress(folders, scenario),
|
||||
duplicateGroups: makeDuplicateGroups(images, scenario),
|
||||
duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null,
|
||||
|
||||
Reference in New Issue
Block a user