// Community-Logos als Bild für den Kartenrenderer: Zeile und PNG laden, dekodieren, einfarbige Logos in der Details-Farbe füllen. // Zeilen und fertige Bilder bleiben im Speicher (je Logo und Details-Farbe nur ein Ladevorgang). Ein Logo, das es nicht (mehr) // gibt, ergibt null; ein echter Ladefehler (Netz, Server) wirft und bleibt nicht im Zwischenspeicher. import { TONE_COLOR, type DetailsTone } from '../card/config'; // Nur der Typ: render.ts importiert dieses Modul (Standard-Lader), zur Laufzeit darf kein Kreis entstehen. import type { LogoAsset } from '../card/render'; import { msg } from '../i18n/i18n'; import { getLogo, loadLogoBlob, type CommunityLogo } from './logos'; /** Hinweis im Editor, wenn die Karte ein Logo wählt, das gelöscht oder ausgeblendet wurde. */ export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.'); /** * Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles * Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet * (und auch nicht als Kachel im Logo-Raster, siehe logo-picker.ts). */ export const MAX_LOGO_PIXELS = 1_000_000; type Decoded = LogoAsset; // Zeilen (auch „gibt es nicht“ = null) und fertige Bilder; die Schlüssel der Bilder sind „:“ (IDs enthalten kein „:“). const rows = new Map>(); const assets = new Map>(); /** Verwirft die fertigen Bilder eines Logos (beide Details-Farben). */ function dropImages(id: string): void { for (const key of assets.keys()) if (key.startsWith(`${id}:`)) assets.delete(key); } /** * Legt eine Zeile, die der Aufrufer schon hat (Logo-Liste, frisch hochgeladenes Logo), in den Zwischenspeicher: spart `getLogo`. * Ist sie ausgeblendet, fallen schon geladene Bilder des Logos weg: ein gesperrtes Logo darf nicht mehr gezeichnet werden. */ export function rememberLogo(logo: CommunityLogo): void { rows.set(logo.id, Promise.resolve(logo)); if (logo.hidden) dropImages(logo.id); } /** Vergisst Zeile und Bilder eines Logos (nach Löschen oder Ausblenden); der nächste Zugriff lädt neu. */ export function forgetLogo(id: string): void { rows.delete(id); dropImages(id); } /** Die Zeile eines Logos, einmal je ID; ein Fehler wird nicht festgehalten. */ function rowFor(id: string): Promise { const cached = rows.get(id); if (cached) return cached; const request = getLogo(id); // Nur entfernen, was noch derselbe Eintrag ist: forgetLogo/rememberLogo können ihn inzwischen ersetzt haben. request.catch(() => { if (rows.get(id) === request) rows.delete(id); }); rows.set(id, request); return request; } /** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */ function isFileMissing(err: unknown): boolean { if (typeof err !== 'object' || err === null) return false; const { code, type } = err as { code?: unknown; type?: unknown }; return code === 404 || type === 'storage_file_not_found'; } /** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */ function decodeWithImage(blob: Blob): Promise { const url = URL.createObjectURL(blob); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve({ source: img, w: img.naturalWidth, h: img.naturalHeight }); img.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.')); img.src = url; }).finally(() => URL.revokeObjectURL(url)); } /** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */ async function decode(blob: Blob): Promise { try { let decoded: Decoded; if (typeof createImageBitmap === 'function') { const bitmap = await createImageBitmap(blob); decoded = { source: bitmap, w: bitmap.width, h: bitmap.height }; } else { decoded = await decodeWithImage(blob); } return decoded.w > 0 && decoded.h > 0 ? decoded : null; } catch { return null; } } /** Ein Canvas in Bildgröße, in dem nur noch die Form des Bildes (sein Alphakanal) steht, gefüllt mit `color`. */ function fillShape({ source, w, h }: Decoded, color: string): HTMLCanvasElement { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.'); ctx.drawImage(source, 0, 0); // „source-in“: die Farbe bleibt nur dort, wo das Bild schon deckt. ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = color; ctx.fillRect(0, 0, w, h); return canvas; } async function build(id: string, tone: DetailsTone): Promise { const logo = await rowFor(id); // Ausgeblendete Logos können Hochlader und Admins noch lesen. Ein gesperrtes Logo darf aber nie in einer Karte stehen, // die sich veröffentlichen lässt: es gilt als nicht verfügbar. if (!logo || logo.hidden) return null; let blob: Blob; try { blob = await loadLogoBlob(logo); } catch (err) { if (isFileMissing(err)) return null; throw err; } const decoded = await decode(blob); if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null; if (!logo.mono) return decoded; return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h }; } /** * Das Community-Logo `id` als Bild für den Renderer. Einfarbige Logos (`mono`) haben die Farbe der Details (Hell/Dunkel), * mehrfarbige bleiben unverändert. null: das Logo gibt es nicht (mehr), ist ausgeblendet, oder seine Datei ist unbrauchbar. * Wirft bei einem echten Ladefehler; der bleibt nicht im Zwischenspeicher, ein späterer Aufruf versucht es neu. */ export function loadLogoAsset(id: string, tone: DetailsTone): Promise { const key = `${id}:${tone}`; const cached = assets.get(key); if (cached) return cached; const request = build(id, tone); request.catch(() => { if (assets.get(key) === request) assets.delete(key); }); assets.set(key, request); return request; }