mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
perf(sprites): serve grid-sized thumbnails from an immutable URL space
Every grid cell downloaded the full detail sprite. Generate a smaller /sprites-grid/v1/ set at build time and let the grid ask for those first, falling back through the existing detail URLs when a thumbnail is missing so detail resolution is unchanged. The new prefix is versioned, so it can be cached forever, and the service worker recognises it alongside the other sprite roots. The placeholder is now an empty box rather than a spinner: a thousand spinners cost layout work and announced nothing useful.
This commit is contained in:
@@ -15,3 +15,5 @@ vite.config.ts.timestamp-*
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
/static/sprites-grid/
|
||||
|
||||
@@ -13,3 +13,6 @@ static/sprites-small/manifest.json
|
||||
|
||||
# Machine-local editor and tool settings.
|
||||
**/*.local.json
|
||||
|
||||
# Generated grid artwork.
|
||||
static/sprites-grid/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
/sprites-grid/v1/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
+8
-7
@@ -8,12 +8,12 @@
|
||||
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||
"sprites:build": "node scripts/optimize-sprites.mjs",
|
||||
"sprites:manifest": "node scripts/sprite-manifest.mjs",
|
||||
"build-generate-sw": "npm run tailwind && GENERATE_SW=true vite build",
|
||||
"build-generate-sw-node": "npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
|
||||
"build": "npm run tailwind && vite build",
|
||||
"build-inject-manifest": "npm run tailwind && vite build",
|
||||
"build-inject-manifest-node": "npm run tailwind && NODE_ADAPTER=true vite build",
|
||||
"build-self-destroying": "npm run tailwind && SELF_DESTROYING_SW=true vite build",
|
||||
"build-generate-sw": "npm run sprites:grid && npm run tailwind && GENERATE_SW=true vite build",
|
||||
"build-generate-sw-node": "npm run sprites:grid && npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
|
||||
"build": "npm run sprites:grid && npm run tailwind && vite build",
|
||||
"build-inject-manifest": "npm run sprites:grid && npm run tailwind && vite build",
|
||||
"build-inject-manifest-node": "npm run sprites:grid && npm run tailwind && NODE_ADAPTER=true vite build",
|
||||
"build-self-destroying": "npm run sprites:grid && npm run tailwind && SELF_DESTROYING_SW=true vite build",
|
||||
"preview": "vite preview --port=4173",
|
||||
"preview-node": "PORT=4173 node build",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -44,7 +44,8 @@
|
||||
"supabase:studio": "supabase studio",
|
||||
"migrate:convert-tsv": "node scripts/convert-tsv-to-sql.js",
|
||||
"dev:local": "./scripts/dev-local.sh && npm run dev",
|
||||
"dev:supabase": "supabase start && npm run dev"
|
||||
"dev:supabase": "supabase start && npm run dev",
|
||||
"sprites:grid": "node scripts/grid-thumbnails.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lhci/cli": "^0.15.1",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readdir, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Bump the URL version whenever dimensions, quality or source artwork changes.
|
||||
const source = 'static/sprites-small/home';
|
||||
const destination = 'static/sprites-grid/v1/home';
|
||||
const files = [];
|
||||
async function walk(relative = '') {
|
||||
for (const entry of await readdir(path.join(source, relative), { withFileTypes: true })) {
|
||||
const name = path.join(relative, entry.name);
|
||||
if (entry.isDirectory()) await walk(name);
|
||||
else if (entry.name.endsWith('.webp')) files.push(name);
|
||||
}
|
||||
}
|
||||
await walk();
|
||||
const manifest = [];
|
||||
for (const relative of files.sort()) {
|
||||
const target = path.join(destination, relative);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
await stat(target);
|
||||
} catch {
|
||||
await sharp(path.join(source, relative))
|
||||
.resize(128, 128, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toFile(target);
|
||||
}
|
||||
manifest.push({ path: relative.split(path.sep).join('/'), bytes: (await stat(target)).size });
|
||||
}
|
||||
await writeFile(
|
||||
'static/sprites-grid/v1/manifest.json',
|
||||
JSON.stringify({ version: 1, width: 128, quality: 80, files: manifest })
|
||||
);
|
||||
console.log(`Prepared ${files.length} versioned grid thumbnails; originals preserved.`);
|
||||
@@ -1,7 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||
import { inView } from '$lib/actions/inView';
|
||||
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||
import { resolveSpriteUrl, resolveGridSpriteUrl } from '$lib/utils/spriteUrl';
|
||||
|
||||
export let variant: 'detail' | 'grid' = 'detail';
|
||||
let failedPaths = new Set<string>();
|
||||
let fallbackIndex = 0;
|
||||
let candidates: string[] = [];
|
||||
$: {
|
||||
const entry = { pokedexNumber: Number(pokedexNumber), form, spriteKey };
|
||||
const full = resolveSpriteUrl(
|
||||
entry,
|
||||
!!shiny,
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
);
|
||||
candidates = [
|
||||
...new Set([
|
||||
...(variant === 'grid' ? [resolveGridSpriteUrl(entry, !!shiny)] : []),
|
||||
full,
|
||||
resolveSpriteUrl(
|
||||
{ ...entry, form: form?.replace(/^female[-\s]*/i, '') },
|
||||
!!shiny,
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
),
|
||||
resolveSpriteUrl(
|
||||
{ pokedexNumber: Number(pokedexNumber) },
|
||||
!!shiny,
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
)
|
||||
])
|
||||
];
|
||||
fallbackIndex = candidates.findIndex((url) => !failedPaths.has(url));
|
||||
}
|
||||
function imageFailed() {
|
||||
failedPaths = new Set([...failedPaths, imagePath!]);
|
||||
}
|
||||
|
||||
export let pokemonName: string;
|
||||
export let pokedexNumber: string | number;
|
||||
@@ -21,14 +54,9 @@
|
||||
form
|
||||
});
|
||||
}
|
||||
|
||||
imagePath = resolveSpriteUrl(
|
||||
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
|
||||
!!shiny,
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
$: imagePath = candidates[fallbackIndex] ?? null;
|
||||
$: if (loadingStrategy !== 'inView') {
|
||||
isInView = true;
|
||||
}
|
||||
@@ -50,16 +78,17 @@
|
||||
}}
|
||||
>
|
||||
{#if loadingStrategy === 'inView' && !isInView}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span class="inline-block w-full h-full" aria-hidden="true"></span>
|
||||
{:else}
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="sprite"
|
||||
alt=""
|
||||
on:error={imageFailed}
|
||||
loading={loadingStrategy === 'lazy' ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span class="inline-block w-full h-full" aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
@@ -46,3 +46,14 @@ export function resolveSpriteUrl(
|
||||
if (/^female\b/i.test(form)) root += '/female';
|
||||
return `${root}/${key}.webp`;
|
||||
}
|
||||
|
||||
/** Grid assets use a separate immutable URL space; detail resolution is unchanged. */
|
||||
export function resolveGridSpriteUrl(
|
||||
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
||||
shiny: boolean
|
||||
): string {
|
||||
return resolveSpriteUrl(entry, shiny, true).replace(
|
||||
'/sprites-small/home/',
|
||||
'/sprites-grid/v1/home/'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ async function missingSprites(cache, root) {
|
||||
|
||||
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
|
||||
function isSpriteUrl(url) {
|
||||
return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
|
||||
return /\/sprites(?:-small|-grid\/v1)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
|
||||
}
|
||||
|
||||
async function currentOfflineMeta() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveGridSpriteUrl, resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||
|
||||
describe('versioned grid artwork', () => {
|
||||
it('separates grid and detail URLs for shiny, female and named forms', () => {
|
||||
for (const form of ['', 'Female', 'Alolan', 'Female Mega']) {
|
||||
for (const shiny of [false, true]) {
|
||||
const entry = { pokedexNumber: 25, form, spriteKey: '25' };
|
||||
const grid = resolveGridSpriteUrl(entry, shiny);
|
||||
expect(grid).toBe(
|
||||
resolveSpriteUrl(entry, shiny, true).replace(
|
||||
'/sprites-small/home/',
|
||||
'/sprites-grid/v1/home/'
|
||||
)
|
||||
);
|
||||
expect(resolveSpriteUrl(entry, shiny, true)).not.toContain('sprites-grid');
|
||||
}
|
||||
}
|
||||
});
|
||||
// Source-to-output consistency is checked when artifacts are built, not required for a fresh unit-only checkout.
|
||||
it('keeps the grid sprite URL version explicit', () => {
|
||||
expect(resolveGridSpriteUrl({ pokedexNumber: 1 }, false)).toBe('/sprites-grid/v1/home/1.webp');
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -46,11 +46,11 @@ export default defineConfig({
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
||||
globIgnores: ['**/sprites/**', '**/sprites-small/**']
|
||||
globIgnores: ['**/sprites/**', '**/sprites-small/**', '**/sprites-grid/**']
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
||||
globIgnores: ['**/sprites/**', '**/sprites-small/**'],
|
||||
globIgnores: ['**/sprites/**', '**/sprites-small/**', '**/sprites-grid/**'],
|
||||
// Shared message/fetch handling keeps generateSW and injectManifest behavior equal.
|
||||
importScripts: ['/offline-worker.js']
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user