perf(tags): render tag mosaics on the server and size pages to the screen

Each tag tile now loads one WebP built by the backend from its newest models'
pictures, instead of up to ten images laid out in the browser. Mosaics are
made on first request and cached under storage/tag-mosaics; their URL carries
a hash of the pictures in them, so tagging, untagging or a new render gives a
new URL with nothing to invalidate, and existing libraries need no backfill.

The summary joins pictures only for the requested page's tags, a single
tag's mosaic looks up just that tag's newest models, and the Tags page asks
for enough tags to fill the screen. Deleting a tag, or all of a user's
models, removes its mosaics.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-15 12:46:42 +02:00
1 parent eed60e2b24
commit eefbde1bb5
12 files changed
+412 -152

No files matched your search

+2
View File
@@ -39,6 +39,8 @@ export const BUNDLES = path.join(STORAGE, "bundles");
export const PREVIEWS = path.join(STORAGE, "previews");
export const DESCRIPTION_IMAGES = path.join(STORAGE, "description-images");
export const MODEL_PREVIEWS = path.join(STORAGE, "model-previews");
// One picture per tag for the Tags page, built from its models' pictures.
export const TAG_MOSAICS = path.join(STORAGE, "tag-mosaics");
// Slicer-compatible copies of MakerWorld 3MFs, rebuilt when the source plate changes.
export const NORMALIZED_3MFS = path.join(STORAGE, "normalized-3mf");
// Large multi-part projects take minutes on a slow CPU; the page waits for it, not the request.
+20 -2
View File
@@ -1,9 +1,27 @@
import fs from "node:fs";
import path from "node:path";
import { PrismaClient } from "@prisma/client";
import { STORAGE, THUMBS, BUNDLES, PREVIEWS, DESCRIPTION_IMAGES, MODEL_PREVIEWS, NORMALIZED_3MFS } from "./config";
import {
STORAGE,
THUMBS,
BUNDLES,
PREVIEWS,
DESCRIPTION_IMAGES,
MODEL_PREVIEWS,
NORMALIZED_3MFS,
TAG_MOSAICS,
} from "./config";
for (const dir of [STORAGE, THUMBS, BUNDLES, PREVIEWS, DESCRIPTION_IMAGES, MODEL_PREVIEWS, NORMALIZED_3MFS]) {
for (const dir of [
STORAGE,
THUMBS,
BUNDLES,
PREVIEWS,
DESCRIPTION_IMAGES,
MODEL_PREVIEWS,
NORMALIZED_3MFS,
TAG_MOSAICS,
]) {
fs.mkdirSync(dir, { recursive: true });
}
// The cache's name before it became NORMALIZED_3MFS; nothing reads it any more.
+11 -3
View File
@@ -209,9 +209,17 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut {
};
}
/** A model's picture for a mosaic: its first image, else its first plate's render; null with neither on disk. */
export function printCoverUrl(previewImageId: string | null, plateId: string | null): string | null {
return (previewImageId && previewImageUrl(previewImageId)) || (plateId && plateThumbUrl(plateId)) || null;
/** A model's picture for a mosaic: its first image, else its first plate's render; null with neither on disk.
* `url` carries the file's mtime, so it changes whenever the picture does. */
export function printCover(
previewImageId: string | null,
plateId: string | null,
): { url: string; file: string } | null {
const image = previewImageId && previewImageUrl(previewImageId);
if (image) return { url: image, file: previewImagePath(previewImageId) };
const plate = plateId && plateThumbUrl(plateId);
if (plate) return { url: plate, file: plateThumbPath(plateId) };
return null;
}
function previewImageUrl(id: string): string | null {
+27 -53
View File
@@ -4,11 +4,17 @@ import { prisma } from "../db";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { normalizeTag } from "../utils/tagNormalization";
import { printCoverUrl } from "../dto";
import { addTagBookmark, listBookmarkedTagSet, removeTagBookmark } from "../services/bookmarkService";
import { createLog } from "../services/auditLog";
import { relocatePrintsForToken } from "../services/printService";
import { HttpError } from "../utils/fileUtils";
import {
ensureTagMosaic,
listTaggedPrints,
removeTagMosaics,
tagMosaicUrl,
tagPictures,
} from "../services/tagMosaicService";
const router = Router();
router.use(requireAuth);
@@ -19,11 +25,6 @@ function sortedBookmarks(tags: Iterable<string>): string[] {
// Every tag in the library (unlike GET /tags, which follows the grid filter).
/** Pictures behind each tag's tile, from its newest models. */
const TAG_THUMBNAILS = 10;
/** Models looked at per tag for those pictures; more than ten, as some models have none yet. */
const TAG_THUMBNAIL_CANDIDATES = 30;
const summaryQuery = z.object({
sort: z.enum(["popular", "name"]).catch("popular"),
limit: z.coerce.number().int().min(1).max(100).catch(48),
@@ -36,15 +37,7 @@ router.get(
"/tags/summary",
asyncHandler(async (req, res) => {
const { sort, limit, offset, min_count: minCount } = summaryQuery.parse(req.query);
const [prints, bookmarked] = await Promise.all([
// Counting needs only the tags; pictures are joined below for this page's tags alone.
prisma.print.findMany({
where: { userId: req.userId },
orderBy: { createdAt: "desc" },
select: { id: true, tags: true },
}),
listBookmarkedTagSet(req.userId!),
]);
const [prints, bookmarked] = await Promise.all([listTaggedPrints(req.userId!), listBookmarkedTagSet(req.userId!)]);
const counts = new Map<string, number>();
for (const print of prints) {
for (const tag of new Set(print.tags.map(normalizeTag))) {
@@ -60,50 +53,18 @@ router.get(
);
const page = matching.slice(offset, offset + limit);
// Each of this page's tags' newest models, then their first image and plate in one query.
const candidates = new Map(page.map(([name]) => [name, [] as string[]]));
for (const print of prints) {
for (const tag of new Set(print.tags.map(normalizeTag))) {
const ids = candidates.get(tag);
if (ids && ids.length < TAG_THUMBNAIL_CANDIDATES) ids.push(print.id);
}
}
const pictureRows = await prisma.print.findMany({
where: { id: { in: [...new Set([...candidates.values()].flat())] } },
select: {
id: true,
previewImages: { orderBy: { position: "asc" }, take: 1, select: { id: true } },
plates: { orderBy: { position: "asc" }, take: 1, select: { id: true } },
},
});
const pictureIds = new Map(pictureRows.map((row) => [row.id, row]));
// A model under several of the page's tags is looked up on disk once.
const covers = new Map<string, string | null>();
const coverOf = (id: string) => {
if (!covers.has(id)) {
const row = pictureIds.get(id);
covers.set(id, row ? printCoverUrl(row.previewImages[0]?.id ?? null, row.plates[0]?.id ?? null) : null);
}
return covers.get(id);
};
const thumbnails = new Map<string, string[]>();
for (const [tag, ids] of candidates) {
const pictures: string[] = [];
// Stops at ten, so the rest of the candidates aren't looked up on disk.
for (const id of ids) {
if (pictures.length >= TAG_THUMBNAILS) break;
const cover = coverOf(id);
if (cover) pictures.push(cover);
}
thumbnails.set(tag, pictures);
}
// Pictures for this page's tags alone, joined in one query.
const pictures = await tagPictures(
prints,
page.map(([name]) => name),
);
res.json({
items: page.map(([name, count]) => ({
name,
count,
bookmarked: bookmarked.has(name),
thumbnails: thumbnails.get(name) ?? [],
mosaic_url: tagMosaicUrl(name, pictures.get(name) ?? []),
})),
total: matching.length,
// Every tag, whatever its count: tells "none match the filter" from "no tags at all".
@@ -113,6 +74,18 @@ router.get(
}),
);
// The tag's tile picture; its URL carries a version, so it's cached for good and asked for anew when it changes.
router.get(
"/tags/:tag/mosaic.webp",
asyncHandler(async (req, res) => {
const file = await ensureTagMosaic(req.userId!, req.params.tag);
if (!file) throw new HttpError(404, "Not found");
res.setHeader("Content-Type", "image/webp");
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
res.sendFile(file);
}),
);
// Just the bookmarked names, without scanning every print like /tags/summary.
router.get(
@@ -155,6 +128,7 @@ router.delete(
),
);
await removeTagBookmark(req.userId!, tag);
await removeTagMosaics(req.userId!, tag);
// Files only move when the storage template is laid out by tags.
await relocatePrintsForToken(
"tags",
+3
View File
@@ -7,6 +7,7 @@ import { deleteAllDescriptionImages } from "./descriptionImageService";
import { deletePlateFiles } from "./printCreation";
import { plateThumbPath } from "./printService";
import { loadFullPrint } from "./printLoader";
import { removeUserTagMosaics } from "./tagMosaicService";
export type UserWithPrintCount = {
id: string;
@@ -97,6 +98,8 @@ export async function deleteAllPrintsForUser(userId: string): Promise<number> {
await fs.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined);
}
}
// With no models left the user has no tags, so none of their tag pictures is needed.
await removeUserTagMosaics(userId);
return prints.length;
}
+204
View File
@@ -0,0 +1,204 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { TAG_MOSAICS } from "../config";
import { prisma } from "../db";
import { printCover } from "../dto";
import { normalizeTag } from "../utils/tagNormalization";
// A tag's tile shows one picture built here from its newest models' pictures, rather than ten pictures
// the browser fetches and lays out itself. It is made the first time it's asked for and kept on disk,
// under a name that changes with the pictures in it, so tagging and untagging models, a new render or a
// replaced image each give it a new URL with nothing to invalidate.
/** Pictures in a tag's mosaic, from its newest models. */
export const TAG_MOSAIC_PICTURES = 10;
/** Models looked at per tag for those pictures; more than ten, as some models have none yet. */
const CANDIDATES = 30;
const SIZE = 480;
const GAP = 4;
// Bumped when the drawing changes, so mosaics made by an older version are made again.
const FORMAT_VERSION = 1;
// The 4 x 4 grid split into as many pictures as the tag has, big and small together. Each letter is
// one picture, a, b, c... in order.
export const MOSAICS: string[][] = [
["aaaa", "aaaa", "aaaa", "aaaa"],
["aabb", "aabb", "aabb", "aabb"],
["aabb", "aabb", "aacc", "aacc"],
["aabc", "aabc", "aadd", "aadd"],
["aabb", "aabb", "cdee", "cdee"],
["aabc", "aabc", "ddee", "ffee"],
["aabc", "aabd", "eeff", "eegg"],
["abcc", "dbcc", "eeff", "ghff"],
["aabc", "aade", "fgii", "hgii"],
["aabc", "aade", "fghh", "fijj"],
];
type Picture = { url: string; file: string };
export type TaggedPrint = { id: string; tags: string[] };
/** Every model's tags, newest model first: enough to count tags and pick their newest models. */
export function listTaggedPrints(userId: string): Promise<TaggedPrint[]> {
return prisma.print.findMany({
where: { userId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: { id: true, tags: true },
});
}
/** One tag's newest models, found by the database: the image endpoint asks for one tag at a time, and a page of
* tiles shouldn't each read every model's tags. Case-insensitive, as tags saved before normalization may vary. */
async function printsTagged(userId: string, tag: string): Promise<TaggedPrint[]> {
const rows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM "Print"
WHERE "userId" = ${userId} AND EXISTS (SELECT 1 FROM unnest(tags) AS t WHERE lower(t) = lower(${tag}))
-- The same order as listTaggedPrints, so the summary's URL and the file made here agree.
ORDER BY "createdAt" DESC, id DESC
LIMIT ${CANDIDATES}`;
return rows.map((row) => ({ id: row.id, tags: [tag] }));
}
/** Each of `tags`' pictures, from its newest models that have one; joins images and plates for those alone. */
export async function tagPictures(prints: TaggedPrint[], tags: string[]): Promise<Map<string, Picture[]>> {
const candidates = new Map(tags.map((tag) => [tag, [] as string[]]));
for (const print of prints) {
for (const tag of new Set(print.tags.map(normalizeTag))) {
const ids = candidates.get(tag);
if (ids && ids.length < CANDIDATES) ids.push(print.id);
}
}
const rows = await prisma.print.findMany({
where: { id: { in: [...new Set([...candidates.values()].flat())] } },
select: {
id: true,
previewImages: { orderBy: { position: "asc" }, take: 1, select: { id: true } },
plates: { orderBy: { position: "asc" }, take: 1, select: { id: true } },
},
});
const byId = new Map(rows.map((row) => [row.id, row]));
// A model under several of the tags is looked up on disk once.
const covers = new Map<string, Picture | null>();
const coverOf = (id: string) => {
if (!covers.has(id)) {
const row = byId.get(id);
covers.set(id, row ? printCover(row.previewImages[0]?.id ?? null, row.plates[0]?.id ?? null) : null);
}
return covers.get(id);
};
const pictures = new Map<string, Picture[]>();
for (const [tag, ids] of candidates) {
const found: Picture[] = [];
// Stops at ten, so the rest of the candidates aren't looked up on disk.
for (const id of ids) {
if (found.length >= TAG_MOSAIC_PICTURES) break;
const cover = coverOf(id);
if (cover) found.push(cover);
}
pictures.set(tag, found);
}
return pictures;
}
/** Changes whenever the pictures, or their order, do: each picture's URL carries its file's mtime. */
function mosaicVersion(pictures: Picture[]): string {
return createHash("sha1")
.update(`${FORMAT_VERSION}\n${pictures.map((picture) => picture.url).join("\n")}`)
.digest("hex")
.slice(0, 16);
}
/** Where the tag's mosaic is served from, or null while none of its models has a picture. */
export function tagMosaicUrl(tag: string, pictures: Picture[]): string | null {
if (!pictures.length) return null;
return `/tags/${encodeURIComponent(tag)}/mosaic.webp?v=${mosaicVersion(pictures)}`;
}
function userDir(userId: string): string {
return path.join(TAG_MOSAICS, `u-${userId}`);
}
/** Files are named by the tag's hash, so any tag name is a safe filename and its older versions are easy to find. */
function tagPrefix(tag: string): string {
return createHash("sha1").update(tag).digest("hex").slice(0, 16);
}
/** Grid cells of each picture, as [left, top, width, height] in quarters of the tile. */
function cells(layout: string[]): [number, number, number, number][] {
const count = new Set(layout.join("")).size;
return Array.from({ length: count }, (_, index) => {
const letter = String.fromCharCode(97 + index);
const spots = layout.flatMap((row, y) => [...row].flatMap((cell, x) => (cell === letter ? [[x, y]] : [])));
const xs = spots.map(([x]) => x);
const ys = spots.map(([, y]) => y);
const [left, top] = [Math.min(...xs), Math.min(...ys)];
return [left, top, Math.max(...xs) - left + 1, Math.max(...ys) - top + 1];
});
}
/** The mosaic of `pictures` (one to ten files), as a square WebP with see-through gaps. */
export async function renderTagMosaic(pictures: { file: string }[]): Promise<Buffer> {
const layout = MOSAICS[pictures.length - 1];
const step = (SIZE + GAP) / 4;
const pieces = await Promise.all(
cells(layout).map(async ([left, top, width, height], index) => {
const [x, y] = [Math.round(left * step), Math.round(top * step)];
const w = Math.round((left + width) * step - GAP) - x;
const h = Math.round((top + height) * step - GAP) - y;
const input = await sharp(pictures[index].file).rotate().resize(w, h, { fit: "cover" }).toBuffer();
return { input, left: x, top: y };
}),
);
// The gaps stay transparent, so the tile's own colour shows through in light and dark themes alike.
return sharp({ create: { width: SIZE, height: SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
.composite(pieces)
.webp({ quality: 80 })
.toBuffer();
}
/** The tag's mosaic file, made now if its pictures have changed since it was last made; null with no pictures. */
export async function ensureTagMosaic(userId: string, rawTag: string): Promise<string | null> {
const tag = normalizeTag(rawTag);
if (!tag) return null;
const pictures = (await tagPictures(await printsTagged(userId, tag), [tag])).get(tag) ?? [];
if (!pictures.length) return null;
const dir = userDir(userId);
const prefix = tagPrefix(tag);
const file = path.join(dir, `${prefix}-${mosaicVersion(pictures)}.webp`);
try {
await fs.access(file);
return file;
} catch {
// Not made yet.
}
await fs.mkdir(dir, { recursive: true });
// Written aside and moved into place, so a request in the meantime never reads half a file.
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(temporary, await renderTagMosaic(pictures));
await fs.rename(temporary, file);
await removeTagMosaics(userId, tag, file);
return file;
}
/** Drops every tag mosaic of the user's, e.g. once all their models are gone. */
export async function removeUserTagMosaics(userId: string): Promise<void> {
await fs.rm(userDir(userId), { recursive: true, force: true });
}
/** Drops the tag's mosaics, all but `keep`: older versions, or every one when the tag is deleted. */
export async function removeTagMosaics(userId: string, rawTag: string, keep?: string): Promise<void> {
const dir = userDir(userId);
const prefix = `${tagPrefix(normalizeTag(rawTag))}-`;
let names: string[];
try {
names = await fs.readdir(dir);
} catch {
return;
}
await Promise.all(
names
.filter((name) => name.startsWith(prefix) && name.endsWith(".webp") && path.join(dir, name) !== keep)
.map((name) => fs.rm(path.join(dir, name), { force: true })),
);
}
+7
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import os from "node:os";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { TAG_MOSAICS } from "../src/config";
const app = createApp();
let adminToken: string;
@@ -107,6 +108,11 @@ describe("admin user management", () => {
});
it("deletes every print belonging to the target user, leaving other users untouched", async () => {
// A tag picture made while the member still had models.
const mosaics = path.join(TAG_MOSAICS, `u-${memberUserId}`);
fs.mkdirSync(mosaics, { recursive: true });
fs.writeFileSync(path.join(mosaics, "tag-version.webp"), "x");
const deleteRes = await request(app)
.post(`/api/admin/users/${memberUserId}/delete-all-prints`)
.set(auth(adminToken));
@@ -119,6 +125,7 @@ describe("admin user management", () => {
const usersRes = await request(app).get("/api/admin/users").set(auth(adminToken));
const member = usersRes.body.find((u: { id: string }) => u.id === memberUserId);
expect(member.print_count).toBe(0);
expect(fs.existsSync(mosaics)).toBe(false);
});
});
+97 -14
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import request from "supertest";
import sharp from "sharp";
@@ -8,9 +9,12 @@ import { prisma } from "../src/db";
import { attachImportedPreviewImages } from "../src/services/importService";
import { plateThumbPath } from "../src/services/printService";
import { previewImagePath } from "../src/services/previewImageService";
import { MOSAICS, renderTagMosaic } from "../src/services/tagMosaicService";
import { TAG_MOSAICS } from "../src/config";
const app = createApp();
let token: string;
let userId: string;
const STL =
"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
@@ -34,11 +38,11 @@ async function uploadStl() {
}
beforeAll(async () => {
token = (
await request(app)
.post("/api/register")
.send({ displayName: "Thumbs", email: `thumbs-${Date.now()}@example.com`, password: "password123" })
).body.token;
const registered = await request(app)
.post("/api/register")
.send({ displayName: "Thumbs", email: `thumbs-${Date.now()}@example.com`, password: "password123" });
token = registered.body.token;
userId = registered.body.user.id;
});
describe("preview images", () => {
@@ -113,7 +117,7 @@ describe("a 3D render made while an import runs", () => {
});
describe("tag tiles", () => {
it("give each tag up to ten pictures, from its newest models that have one", async () => {
it("show one picture built from the tag's newest models, made anew when their pictures change", async () => {
const tag = `Mosaic-${Date.now()}`;
const prints: { id: string; plateId: string }[] = [];
for (let i = 0; i < 12; i++) {
@@ -132,15 +136,94 @@ describe("tag tiles", () => {
.jpeg()
.toFile(plateThumbPath(plateId));
}
const tile = async () =>
(
(await request(app).get("/api/tags/summary").set(auth())).body.items as {
name: string;
mosaic_url: string | null;
}[]
).find((t) => t.name === tag)!;
const res = await request(app).get("/api/tags/summary").set(auth());
const tile = (res.body.items as { name: string; count: number; thumbnails: string[] }[]).find(
(t) => t.name === tag,
)!;
expect(tile.count).toBe(12);
expect(tile.thumbnails).toHaveLength(10);
expect(tile.thumbnails[0]).toContain(`/plate/${prints[10].plateId}/thumb.jpg`);
expect(tile.thumbnails.some((url) => url.includes(prints[11].plateId))).toBe(false);
const before = (await tile()).mosaic_url!;
expect(before).toMatch(new RegExp(`^/tags/${tag}/mosaic\\.webp\\?v=[0-9a-f]{16}$`));
const image = await request(app)
.get(`/api${before}`)
.set(auth())
.buffer(true)
.parse((res, done) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => done(null, Buffer.concat(chunks)));
});
expect(image.status).toBe(200);
expect(image.headers["content-type"]).toBe("image/webp");
expect(await sharp(image.body as Buffer).metadata()).toMatchObject({ format: "webp", width: 480, height: 480 });
const files = await fs.readdir(path.join(TAG_MOSAICS, `u-${userId}`));
expect(files).toHaveLength(1);
// Taking the tag off one of its pictured models changes what the tile shows, so its URL too.
await request(app).post(`/api/print/${prints[10].id}/tags`).set(auth()).send({ tags: [] });
const after = (await tile()).mosaic_url!;
expect(after).not.toBe(before);
expect((await request(app).get(`/api${after}`).set(auth())).status).toBe(200);
// The older version is gone once the new one is made.
const now = await fs.readdir(path.join(TAG_MOSAICS, `u-${userId}`));
expect(now).toHaveLength(1);
expect(now).not.toEqual(files);
});
it("have no picture while none of the tag's models has one", async () => {
const tag = `Bare-${Date.now()}`;
const res = await request(app).post("/api/upload").set(auth()).attach("files", Buffer.from(STL), "bare.stl");
await request(app)
.post(`/api/print/${res.body.prints[0].id}/tags`)
.set(auth())
.send({ tags: [tag] });
const items = (await request(app).get("/api/tags/summary").set(auth())).body.items as {
name: string;
mosaic_url: string | null;
}[];
expect(items.find((t) => t.name === tag)!.mosaic_url).toBeNull();
expect((await request(app).get(`/api/tags/${tag}/mosaic.webp`).set(auth())).status).toBe(404);
});
it("draw each picture into its own cell, leaving the gaps between them see-through", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mosaic-"));
const files = await Promise.all(
[0, 120, 240].map(async (hue, i) => {
const file = path.join(dir, `${i}.jpg`);
await sharp(await png(hue))
.jpeg()
.toFile(file);
return { file };
}),
);
const { data, info } = await sharp(await renderTagMosaic(files))
.raw()
.toBuffer({ resolveWithObject: true });
const pixel = (x: number, y: number) => [...data.subarray((y * info.width + x) * 4, (y * info.width + x) * 4 + 4)];
// Three pictures: the first fills the left half, the other two the right quarters (see MOSAICS).
expect(pixel(100, 240)[0]).toBeLessThan(40);
expect(pixel(360, 100)[0]).toBeGreaterThan(100);
expect(pixel(360, 380)[0]).toBeGreaterThan(200);
// The gap down the middle shows whatever is behind the tile.
expect(pixel(239, 240)[3]).toBe(0);
await fs.rm(dir, { recursive: true });
});
it("split the 4 x 4 grid into exactly n rectangles for n pictures", () => {
MOSAICS.forEach((rows, index) => {
expect(rows).toHaveLength(4);
const letters = [...new Set(rows.join(""))].toSorted();
expect(letters).toEqual(Array.from({ length: index + 1 }, (_, i) => String.fromCharCode(97 + i)));
for (const letter of letters) {
const cells = rows.flatMap((row, y) => [...row].flatMap((cell, x) => (cell === letter ? [[x, y]] : [])));
const width = Math.max(...cells.map(([x]) => x)) - Math.min(...cells.map(([x]) => x)) + 1;
const height = Math.max(...cells.map(([, y]) => y)) - Math.min(...cells.map(([, y]) => y)) + 1;
// One rectangle: every cell of its bounding box is that letter.
expect(cells).toHaveLength(width * height);
}
});
});
it("come a page at a time, leaving out tags under the minimum count", async () => {
+2 -2
View File
@@ -7,8 +7,8 @@ export type TagSummary = {
name: string;
count: number;
bookmarked: boolean;
/** Up to 10 pictures of its newest models, for its tile. */
thumbnails: string[];
/** One picture of its newest models, built and cached by the server; null while none has a picture. */
mosaic_url: string | null;
};
export type TagSummaryPage = {
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { MOSAICS } from "./TagTile";
describe("tag tile mosaics", () => {
it("split the 4 x 4 grid into exactly n rectangles for n pictures", () => {
MOSAICS.forEach((rows, index) => {
const pictures = index + 1;
expect(rows).toHaveLength(4);
const letters = [...new Set(rows.join(""))].toSorted();
// a, b, c... one letter per picture, in order.
expect(letters).toEqual(Array.from({ length: pictures }, (_, i) => String.fromCharCode(97 + i)));
for (const letter of letters) {
const cells = rows.flatMap((row, y) => [...row].flatMap((cell, x) => (cell === letter ? [[x, y]] : [])));
const xs = cells.map(([x]) => x);
const ys = cells.map(([, y]) => y);
const width = Math.max(...xs) - Math.min(...xs) + 1;
const height = Math.max(...ys) - Math.min(...ys) + 1;
// A CSS grid area must be one rectangle: every cell of its bounding box is that letter.
expect(cells).toHaveLength(width * height);
}
});
});
});
+27 -50
View File
@@ -14,20 +14,6 @@ import type { TagSummary } from "../../api/tags";
import { colorForTag } from "../../utils/tagColors";
import TagActionsMenu from "../TagDetailPage/TagActionsMenu";
// A 4 x 4 grid split into as many pictures as the tag has, big and small together. Each letter is one picture.
export const MOSAICS: string[][] = [
["aaaa", "aaaa", "aaaa", "aaaa"],
["aabb", "aabb", "aabb", "aabb"],
["aabb", "aabb", "aacc", "aacc"],
["aabc", "aabc", "aadd", "aadd"],
["aabb", "aabb", "cdee", "cdee"],
["aabc", "aabc", "ddee", "ffee"],
["aabc", "aabd", "eeff", "eegg"],
["abcc", "dbcc", "eeff", "ghff"],
["aabc", "aade", "fgii", "hgii"],
["aabc", "aade", "fghh", "fijj"],
];
/** Grid areas for a mosaic's pictures, from its rows of letters. */
const areas = (rows: string[]) => rows.map((row) => `"${row.split("").join(" ")}"`).join(" ");
@@ -48,8 +34,6 @@ const overlayButtonSx = {
/** A square for a tag: a mosaic of its models, its name, count and bookmark along the bottom, and its menu on hover. */
function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const pictures = tag.thumbnails.slice(0, MOSAICS.length);
const layout = pictures.length ? MOSAICS[pictures.length - 1] : null;
const fallback = colorForTag(tag.name);
const bookmarkLabel = tag.bookmarked ? t("models:tags.unbookmarkTag") : t("models:tags.bookmarkTag");
@@ -62,7 +46,7 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
overflow: "hidden",
border: "1px solid",
borderColor: "divider",
bgcolor: layout ? "divider" : fallback.bg,
bgcolor: tag.mosaic_url ? "divider" : fallback.bg,
transition: "transform 150ms, box-shadow 150ms",
"&:hover": { transform: "translateY(-2px)", boxShadow: "0 6px 16px rgba(0, 0, 0, 0.16)" },
"&:hover .tag-mosaic img": { transform: "scale(1.04)" },
@@ -82,37 +66,23 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: -3 },
}}
>
{layout ? (
<Box
className="tag-mosaic"
aria-hidden
sx={{
position: "absolute",
inset: 0,
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gridTemplateRows: "repeat(4, 1fr)",
gridTemplateAreas: areas(layout),
gap: "2px",
}}
>
{pictures.map((url, index) => (
<Box key={url} sx={{ gridArea: String.fromCharCode(97 + index), overflow: "hidden", minHeight: 0 }}>
<Box
component="img"
src={printsApi.fileUrl(url)}
alt=""
loading="lazy"
sx={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
transition: "transform 300ms",
}}
/>
</Box>
))}
{tag.mosaic_url ? (
// Built on the server from the tag's newest models: one picture per tile, however many models.
<Box className="tag-mosaic" aria-hidden sx={{ position: "absolute", inset: 0, overflow: "hidden" }}>
<Box
component="img"
src={printsApi.fileUrl(tag.mosaic_url)}
alt=""
loading="lazy"
decoding="async"
sx={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
transition: "transform 300ms",
}}
/>
</Box>
) : (
<LocalOfferOutlinedIcon
@@ -212,8 +182,15 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
// The page passes the same callbacks every time, so only a tile whose tag changed draws again.
export default memo(TagTile);
// A few different mosaics, so a loading grid doesn't look stamped out.
const SKELETON_MOSAICS = [4, 6, 2, 9, 5, 7].map((count) => MOSAICS[count]);
// A few mosaics like the server draws, so a loading grid doesn't look stamped out.
const SKELETON_MOSAICS = [
["aabb", "aabb", "cdee", "cdee"],
["aabc", "aabd", "eeff", "eegg"],
["aabb", "aabb", "aacc", "aacc"],
["aabc", "aade", "fghh", "fijj"],
["aabc", "aabc", "ddee", "ffee"],
["abcc", "dbcc", "eeff", "ghff"],
];
/** Stand-ins shaped like tiles: a mosaic of blocks, a pill for the name, a circle for the bookmark. */
export function TagTileSkeletons({ count }: { count: number }) {
+12 -5
View File
@@ -17,7 +17,13 @@ type Props = {
onBookmarksChanged?: () => void;
};
const PAGE_SIZE = 30;
/** Enough tags to fill the screen about one and a half times, so a big one is filled by its first request. */
function pageSizeForScreen(): number {
const tile = 172; // a 160px tile and its gap
const columns = Math.max(2, Math.floor(window.innerWidth / tile));
const rows = Math.ceil(window.innerHeight / tile);
return Math.min(100, Math.max(30, Math.ceil(columns * rows * 1.5)));
}
const TILE_GRID = {
display: "grid",
@@ -37,6 +43,7 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
const [sortMode, setSortMode] = useState<TagSortMode>("popular");
const [hideRarelyUsed, setHideRarelyUsed] = useState(true);
const minCount = hideRarelyUsed ? 2 : 1;
const [pageSize] = useState(pageSizeForScreen);
// Bumped whenever the sort or filter changes, so a page still on its way for the old one is dropped.
const generation = useRef(0);
@@ -56,7 +63,7 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
setTags([]);
(async () => {
try {
const page = await tagsApi.listSummary({ sort: sortMode, limit: PAGE_SIZE, offset: 0, minCount });
const page = await tagsApi.listSummary({ sort: sortMode, limit: pageSize, offset: 0, minCount });
if (current !== generation.current) return;
setTags(page.items);
setHasMore(page.has_more);
@@ -68,13 +75,13 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortMode, minCount]);
}, [sortMode, minCount, pageSize]);
const loadMore = useCallback(async () => {
const current = generation.current;
setLoadingMore(true);
try {
const page = await tagsApi.listSummary({ sort: sortMode, limit: PAGE_SIZE, offset: tags.length, minCount });
const page = await tagsApi.listSummary({ sort: sortMode, limit: pageSize, offset: tags.length, minCount });
if (current !== generation.current) return;
setTags((prev) => [...prev, ...page.items]);
setHasMore(page.has_more);
@@ -85,7 +92,7 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
setLoadingMore(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortMode, minCount, tags.length]);
}, [sortMode, minCount, pageSize, tags.length]);
const sentinelRef = useInfiniteScroll(() => void loadMore(), hasMore, loading || loadingMore);