feat(card): Netzwerk- und Marken-Logos

18 Netzwerk-Logos, AirCards-Symbol/-Text und Apple als bereinigte SVGs
(Wikimedia Commons, gemeinfrei; abgeleitete Varianten selbst gebaut),
Register marks.ts mit Einfärben (currentColor) und Bild-Lader sowie
festfarbiges Favicon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:42:10 +00:00
1 parent 1a4ed0d595
commit eb6b4ac59d
24 files changed
+318 -4

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
// Logo-Register: SVG-Dateien der Netzwerke und Marken, Einfärben und Laden als Bild für den Canvas.
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
import visaAuto from '../assets/marks/visa-auto.svg?raw';
import visaBlue from '../assets/marks/visa-blue.svg?raw';
import visaNavy from '../assets/marks/visa-navy.svg?raw';
import visaSilver from '../assets/marks/visa-silver.svg?raw';
import mcColor from '../assets/marks/mc-color.svg?raw';
import mcSilver from '../assets/marks/mc-silver.svg?raw';
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
import mcClassic from '../assets/marks/mc-classic.svg?raw';
import upColor from '../assets/marks/up-color.svg?raw';
import upSilver from '../assets/marks/up-silver.svg?raw';
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
import upEnglish from '../assets/marks/up-english.svg?raw';
import amexBlue from '../assets/marks/amex-blue.svg?raw';
import amexOutline from '../assets/marks/amex-outline.svg?raw';
import amexSelect from '../assets/marks/amex-select.svg?raw';
import jcb from '../assets/marks/jcb.svg?raw';
import discover from '../assets/marks/discover.svg?raw';
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
import aircardsText from '../assets/marks/aircards-text.svg?raw';
import apple from '../assets/marks/apple.svg?raw';
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
export interface MarkDef {
id: NetworkId | Exclude<BrandId, 'none'>;
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
group: MarkGroup;
label: string;
svg: string;
/** true: enthält `currentColor` und folgt der Details-Farbe. */
tint: boolean;
/** Höchsthöhe auf der Karte in px (Breite folgt aus dem Seitenverhältnis der viewBox). */
maxH: number;
}
type NetworkEntry = Omit<MarkDef, 'id'>;
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
};
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
'aircards-symbol': { group: 'Logo', label: 'AirCards · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: 'Logo', label: 'AirCards · Text', svg: aircardsText, tint: true, maxH: 100 },
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
};
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
export const BRAND_MARKS: readonly MarkDef[] = (
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
).map((id) => ({ id, ...BRAND_TABLE[id] }));
export function getNetworkMark(id: NetworkId): MarkDef {
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
}
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
}
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
export function svgAspect(svg: string): number {
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
if (!hit) return 1;
const parts = hit[1].trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
}
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
export function tintSvg(svg: string, tone: DetailsTone): string {
return svg.replaceAll('currentColor', TONE_COLOR[tone]);
}
/** Gibt dem Wurzelelement eine eigene Größe (viermal maxH hoch), damit alle Browser scharf auf den Canvas zeichnen. */
function withIntrinsicSize(svg: string, maxH: number): string {
const h = maxH * 4;
const w = Math.round(h * svgAspect(svg) * 100) / 100;
return svg.replace('<svg', `<svg width="${w}" height="${h}"`);
}
const imageCache = new Map<string, Promise<HTMLImageElement>>();
/** Lädt ein Logo (eingefärbt) als Bild für `drawImage`. Je (id, tone) wird nur einmal geladen. */
export function loadMarkImage(def: MarkDef, tone: DetailsTone): Promise<HTMLImageElement> {
const key = `${def.id}:${tone}`;
let pending = imageCache.get(key);
if (!pending) {
const source = withIntrinsicSize(tintSvg(def.svg, tone), def.maxH);
pending = new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error(`Logo konnte nicht geladen werden: ${def.id}`));
};
img.src = url;
});
pending.catch(() => imageCache.delete(key));
imageCache.set(key, pending);
}
return pending;
}