feat(card): Konfiguration, Geometrie und Bildausschnitt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05a735993b
commit
1a4ed0d595
6 files changed
+858
No files matched your search
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CROP,
|
||||
NETWORK_IDS,
|
||||
TONE_COLOR,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
type CardConfig,
|
||||
} from './config';
|
||||
|
||||
const custom: CardConfig = {
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
network: 'jcb',
|
||||
details: 'dark',
|
||||
brand: 'apple',
|
||||
crop: { x: -120.5, y: 33, zoom: 2.5 },
|
||||
};
|
||||
|
||||
describe('Standardwerte', () => {
|
||||
it('DEFAULT_CONFIG hat die vereinbarten Werte', () => {
|
||||
expect(DEFAULT_CONFIG).toEqual({
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
});
|
||||
expect(DEFAULT_CROP).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
expect(DEFAULT_CONFIG.crop).toEqual(DEFAULT_CROP);
|
||||
});
|
||||
|
||||
it('DEFAULT_CONFIG.crop ist ein eigenes Objekt, nicht DEFAULT_CROP', () => {
|
||||
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
|
||||
});
|
||||
|
||||
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => {
|
||||
expect(NETWORK_IDS.length).toBe(18);
|
||||
expect([...NETWORK_IDS]).toEqual([
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
]);
|
||||
expect(new Set(NETWORK_IDS).size).toBe(18);
|
||||
});
|
||||
|
||||
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => {
|
||||
expect(TONE_COLOR).toEqual({ light: '#FFFFFF', dark: '#111111' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseConfig / serializeConfig', () => {
|
||||
it('Rundlauf ergibt dieselbe Konfiguration', () => {
|
||||
expect(parseConfig(serializeConfig(custom))).toEqual(custom);
|
||||
expect(parseConfig(serializeConfig(DEFAULT_CONFIG))).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('null, undefined, leer und kaputtes JSON ergeben die Standardkonfiguration', () => {
|
||||
expect(parseConfig(null)).toEqual(DEFAULT_CONFIG);
|
||||
expect(parseConfig(undefined)).toEqual(DEFAULT_CONFIG);
|
||||
expect(parseConfig('')).toEqual(DEFAULT_CONFIG);
|
||||
expect(parseConfig('kaputt{')).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('JSON, das kein Objekt ist, ergibt die Standardkonfiguration', () => {
|
||||
for (const raw of ['null', '42', '"signature"', '[]', 'true']) {
|
||||
expect(parseConfig(raw)).toEqual(DEFAULT_CONFIG);
|
||||
}
|
||||
});
|
||||
|
||||
it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'paypal' });
|
||||
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network });
|
||||
});
|
||||
|
||||
it('jedes Feld wird einzeln validiert', () => {
|
||||
const raw = JSON.stringify({ ...custom, template: 'foo', details: 'blue', brand: 5 });
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
...custom,
|
||||
template: DEFAULT_CONFIG.template,
|
||||
details: DEFAULT_CONFIG.details,
|
||||
brand: DEFAULT_CONFIG.brand,
|
||||
});
|
||||
});
|
||||
|
||||
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' });
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
...custom,
|
||||
network: DEFAULT_CONFIG.network,
|
||||
template: DEFAULT_CONFIG.template,
|
||||
brand: DEFAULT_CONFIG.brand,
|
||||
});
|
||||
});
|
||||
|
||||
it('fehlende Felder bekommen den Standard', () => {
|
||||
expect(parseConfig('{"template":"image"}')).toEqual({ ...DEFAULT_CONFIG, template: 'image' });
|
||||
expect(parseConfig('{}')).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('zoom wird auf 1 bis 4 begrenzt', () => {
|
||||
expect(parseConfig(JSON.stringify({ ...custom, crop: { x: 0, y: 0, zoom: 99 } })).crop.zoom).toBe(4);
|
||||
expect(parseConfig(JSON.stringify({ ...custom, crop: { x: 0, y: 0, zoom: 0.2 } })).crop.zoom).toBe(1);
|
||||
expect(parseConfig(JSON.stringify({ ...custom, crop: { x: 0, y: 0, zoom: -3 } })).crop.zoom).toBe(1);
|
||||
});
|
||||
|
||||
it('nicht endliche oder falsch typisierte Zahlen fallen auf den Standard des Feldes zurück', () => {
|
||||
// NaN und Infinity werden von JSON.stringify zu null
|
||||
const nan = serializeConfig({ ...custom, crop: { x: NaN, y: Infinity, zoom: NaN } });
|
||||
expect(parseConfig(nan).crop).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
const text = parseConfig('{"crop":{"x":"12","y":"NaN","zoom":"3"}}');
|
||||
expect(text.crop).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
});
|
||||
|
||||
it('crop.x NaN setzt nur x zurück', () => {
|
||||
const raw = serializeConfig({ ...custom, crop: { x: NaN, y: 33, zoom: 2.5 } });
|
||||
expect(parseConfig(raw).crop).toEqual({ x: 0, y: 33, zoom: 2.5 });
|
||||
});
|
||||
|
||||
it('fehlender oder kaputter crop ergibt den Standard-Ausschnitt', () => {
|
||||
expect(parseConfig('{"crop":"abc"}').crop).toEqual(DEFAULT_CROP);
|
||||
expect(parseConfig('{"crop":null}').crop).toEqual(DEFAULT_CROP);
|
||||
expect(parseConfig('{"crop":[1,2,3]}').crop).toEqual(DEFAULT_CROP);
|
||||
expect(parseConfig('{"template":"minimal"}').crop).toEqual(DEFAULT_CROP);
|
||||
});
|
||||
|
||||
it('x und y werden nicht begrenzt (das macht clampCrop mit dem Bild)', () => {
|
||||
const c = parseConfig(JSON.stringify({ ...custom, crop: { x: -9999, y: 5000, zoom: 1 } }));
|
||||
expect(c.crop).toEqual({ x: -9999, y: 5000, zoom: 1 });
|
||||
});
|
||||
|
||||
it('unbekannte Felder werden verworfen', () => {
|
||||
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
|
||||
const c = parseConfig(raw);
|
||||
expect(c).toEqual(custom);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
|
||||
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt nur bekannte Felder', () => {
|
||||
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
|
||||
expect(s).not.toContain('evil');
|
||||
expect(JSON.parse(s)).toEqual(custom);
|
||||
});
|
||||
|
||||
it('serializeConfig bleibt unter 2000 Zeichen', () => {
|
||||
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
|
||||
const worst: CardConfig = {
|
||||
...custom,
|
||||
network: 'mc-silver-text',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
|
||||
};
|
||||
expect(serializeConfig(worst).length).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('parseConfig liefert immer neue Objekte, die Standardwerte lassen sich nicht verändern', () => {
|
||||
const a = parseConfig(null);
|
||||
const b = parseConfig(null);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).not.toBe(DEFAULT_CONFIG);
|
||||
expect(a.crop).not.toBe(b.crop);
|
||||
expect(a.crop).not.toBe(DEFAULT_CONFIG.crop);
|
||||
expect(a.crop).not.toBe(DEFAULT_CROP);
|
||||
|
||||
a.crop.x = 500;
|
||||
a.template = 'image';
|
||||
expect(parseConfig(null)).toEqual(DEFAULT_CONFIG);
|
||||
expect(DEFAULT_CONFIG.crop.x).toBe(0);
|
||||
expect(DEFAULT_CONFIG.template).toBe('signature');
|
||||
expect(DEFAULT_CROP).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
|
||||
const c = parseConfig('{"template":"image"}');
|
||||
c.crop.zoom = 3;
|
||||
expect(DEFAULT_CROP.zoom).toBe(1);
|
||||
expect(parseConfig('{}').crop.zoom).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// Kartenkonfiguration: was der Nutzer im Editor einstellt und in der Tabelle `cards` speichert.
|
||||
// Reine Daten, kein DOM, keine Appwrite-Aufrufe.
|
||||
|
||||
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
|
||||
const TONES = ['light', 'dark'] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const;
|
||||
const NETWORKS = [
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
] as const;
|
||||
|
||||
export type TemplateId = (typeof TEMPLATES)[number];
|
||||
export type DetailsTone = (typeof TONES)[number];
|
||||
export type BrandId = (typeof BRANDS)[number];
|
||||
export type NetworkId = (typeof NETWORKS)[number];
|
||||
|
||||
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
|
||||
|
||||
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
|
||||
export const MIN_ZOOM = 1;
|
||||
export const MAX_ZOOM = 4;
|
||||
|
||||
/** x/y = Versatz der Bildmitte zur Kartenmitte in Karten-px, zoom >= 1 relativ zum Cover-Maßstab. */
|
||||
export interface Crop {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface CardConfig {
|
||||
v: 1;
|
||||
template: TemplateId;
|
||||
network: NetworkId;
|
||||
details: DetailsTone;
|
||||
brand: BrandId;
|
||||
crop: Crop;
|
||||
}
|
||||
|
||||
export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
|
||||
|
||||
export const DEFAULT_CONFIG: CardConfig = {
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { ...DEFAULT_CROP },
|
||||
};
|
||||
|
||||
export const TONE_COLOR: Record<DetailsTone, string> = {
|
||||
light: '#FFFFFF',
|
||||
dark: '#111111',
|
||||
};
|
||||
|
||||
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function finite(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseCrop(value: unknown): Crop {
|
||||
const c = isRecord(value) ? value : {};
|
||||
const zoom = finite(c.zoom, DEFAULT_CROP.zoom);
|
||||
return {
|
||||
x: finite(c.x, DEFAULT_CROP.x),
|
||||
y: finite(c.y, DEFAULT_CROP.y),
|
||||
zoom: Math.min(Math.max(zoom, MIN_ZOOM), MAX_ZOOM),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
|
||||
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
|
||||
*/
|
||||
export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
let raw: unknown = null;
|
||||
if (json) {
|
||||
try {
|
||||
raw = JSON.parse(json);
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
const r = isRecord(raw) ? raw : {};
|
||||
return {
|
||||
v: 1,
|
||||
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
|
||||
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
|
||||
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
|
||||
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
|
||||
crop: parseCrop(r.crop),
|
||||
};
|
||||
}
|
||||
|
||||
/** Schreibt nur die bekannten Felder (feste Reihenfolge), damit die Spalte klein bleibt. */
|
||||
export function serializeConfig(c: CardConfig): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
template: c.template,
|
||||
network: c.network,
|
||||
details: c.details,
|
||||
brand: c.brand,
|
||||
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEFAULT_CROP } from './config';
|
||||
import { CARD_H, CARD_W } from './geometry';
|
||||
import { clampCrop, coverScale, imageRect, panBy, zoomTo } from './crop';
|
||||
|
||||
const wide = { w: 3000, h: 1000 };
|
||||
const tall = { w: 1000, h: 3000 };
|
||||
const pano = { w: 4000, h: 200 };
|
||||
const exact = { w: CARD_W, h: CARD_H };
|
||||
|
||||
describe('coverScale', () => {
|
||||
it('nimmt den größeren der beiden Faktoren', () => {
|
||||
expect(coverScale(3000, 1000)).toBeCloseTo(0.969, 3);
|
||||
expect(coverScale(1000, 3000)).toBeCloseTo(1.536, 3);
|
||||
expect(coverScale(CARD_W, CARD_H)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imageRect', () => {
|
||||
it('Standard-Ausschnitt ist mittig und deckt die Karte', () => {
|
||||
for (const img of [wide, tall, pano, exact, { w: 800, h: 600 }]) {
|
||||
const r = imageRect(img, DEFAULT_CROP);
|
||||
expect(r.x).toBeLessThanOrEqual(1e-9);
|
||||
expect(r.y).toBeLessThanOrEqual(1e-9);
|
||||
expect(r.x + r.w).toBeGreaterThanOrEqual(CARD_W - 1e-9);
|
||||
expect(r.y + r.h).toBeGreaterThanOrEqual(CARD_H - 1e-9);
|
||||
// mittig: Bildmitte = Kartenmitte
|
||||
expect(r.x + r.w / 2).toBeCloseTo(CARD_W / 2, 1);
|
||||
expect(r.y + r.h / 2).toBeCloseTo(CARD_H / 2, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('Bild mit Kartenformat füllt genau 0..1536 × 0..969', () => {
|
||||
expect(imageRect(exact, DEFAULT_CROP)).toEqual({ x: 0, y: 0, w: CARD_W, h: CARD_H });
|
||||
});
|
||||
|
||||
it('Breitbild 3000×1000: Höhe 969, Breite 2907, links bei -685.5', () => {
|
||||
const r = imageRect(wide, DEFAULT_CROP);
|
||||
expect(r.w).toBeCloseTo(2907, 1);
|
||||
expect(r.h).toBeCloseTo(969, 1);
|
||||
expect(r.x).toBeCloseTo(-685.5, 1);
|
||||
expect(r.y).toBeCloseTo(0, 1);
|
||||
});
|
||||
|
||||
it('Panorama 4000×200 deckt bei zoom 1 die volle Höhe', () => {
|
||||
const r = imageRect(pano, DEFAULT_CROP);
|
||||
expect(r.h).toBeGreaterThanOrEqual(CARD_H);
|
||||
});
|
||||
|
||||
it('Zoom skaliert um die Bildmitte, Versatz verschiebt die Bildmitte', () => {
|
||||
const r = imageRect(wide, { x: 100, y: -20, zoom: 2 });
|
||||
expect(r.w).toBeCloseTo(2907 * 2, 1);
|
||||
expect(r.h).toBeCloseTo(969 * 2, 1);
|
||||
expect(r.x + r.w / 2).toBeCloseTo(CARD_W / 2 + 100, 1);
|
||||
expect(r.y + r.h / 2).toBeCloseTo(CARD_H / 2 - 20, 1);
|
||||
});
|
||||
|
||||
it('klemmt selbst nicht', () => {
|
||||
const r = imageRect(wide, { x: 9999, y: 0, zoom: 1 });
|
||||
expect(r.x + r.w / 2).toBeCloseTo(CARD_W / 2 + 9999, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampCrop', () => {
|
||||
it('Breitbild: Versatz in x auf ±685.5 begrenzt, y auf 0', () => {
|
||||
expect(clampCrop(wide, { x: 9999, y: 0, zoom: 1 }).x).toBeCloseTo(685.5, 1);
|
||||
expect(clampCrop(wide, { x: -9999, y: 0, zoom: 1 }).x).toBeCloseTo(-685.5, 1);
|
||||
expect(clampCrop(wide, { x: 0, y: 300, zoom: 1 }).y).toBeCloseTo(0, 1);
|
||||
});
|
||||
|
||||
it('Hochformat: x-Grenze 0, y-Grenze größer 0', () => {
|
||||
const c = clampCrop(tall, { x: 500, y: 99999, zoom: 1 });
|
||||
expect(c.x).toBeCloseTo(0, 1);
|
||||
expect(c.y).toBeGreaterThan(0);
|
||||
// 3000 × 1.536 = 4608 hoch, (4608 - 969) / 2
|
||||
expect(c.y).toBeCloseTo(1819.5, 1);
|
||||
});
|
||||
|
||||
it('Zoom erweitert den Verschiebebereich', () => {
|
||||
const c = clampCrop(wide, { x: 9999, y: 9999, zoom: 2 });
|
||||
expect(c.x).toBeCloseTo((2907 * 2 - CARD_W) / 2, 1);
|
||||
expect(c.y).toBeCloseTo((969 * 2 - CARD_H) / 2, 1);
|
||||
});
|
||||
|
||||
it('zoom wird auf 1 bis 4 begrenzt', () => {
|
||||
expect(clampCrop(wide, { x: 0, y: 0, zoom: 99 }).zoom).toBe(4);
|
||||
expect(clampCrop(wide, { x: 0, y: 0, zoom: 0.1 }).zoom).toBe(1);
|
||||
expect(clampCrop(wide, { x: 0, y: 0, zoom: 2.5 }).zoom).toBe(2.5);
|
||||
});
|
||||
|
||||
it('zoom wird vor dem Versatz begrenzt', () => {
|
||||
const c = clampCrop(wide, { x: 1e9, y: 1e9, zoom: 99 });
|
||||
expect(c.zoom).toBe(4);
|
||||
expect(c.x).toBeCloseTo((2907 * 4 - CARD_W) / 2, 1);
|
||||
});
|
||||
|
||||
it('nicht endliche Zahlen fallen auf den Standardwert zurück', () => {
|
||||
expect(clampCrop(wide, { x: NaN, y: Infinity, zoom: NaN })).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
expect(clampCrop(wide, { x: -Infinity, y: NaN, zoom: Infinity })).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
});
|
||||
|
||||
it('das Ergebnis lässt nie einen leeren Streifen: Bild deckt immer 0..1536 × 0..969', () => {
|
||||
const imgs = [wide, tall, pano, exact, { w: 800, h: 600 }, { w: 4032, h: 3024 }, { w: 1920, h: 1080 }];
|
||||
const crops = [-1e6, -300, 0, 300, 1e6];
|
||||
for (const img of imgs) {
|
||||
for (const zoom of [1, 1.7, 4, 50]) {
|
||||
for (const x of crops) {
|
||||
for (const y of crops) {
|
||||
const r = imageRect(img, clampCrop(img, { x, y, zoom }));
|
||||
expect(r.x).toBeLessThanOrEqual(1e-6);
|
||||
expect(r.y).toBeLessThanOrEqual(1e-6);
|
||||
expect(r.x + r.w).toBeGreaterThanOrEqual(CARD_W - 1e-6);
|
||||
expect(r.y + r.h).toBeGreaterThanOrEqual(CARD_H - 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gültiger Ausschnitt bleibt unverändert, Eingabe wird nicht verändert', () => {
|
||||
const input = { x: 100, y: 0, zoom: 1.5 };
|
||||
const out = clampCrop(wide, input);
|
||||
expect(out).toEqual({ x: 100, y: 0, zoom: 1.5 });
|
||||
expect(out).not.toBe(input);
|
||||
clampCrop(wide, { ...input, x: 99999 });
|
||||
expect(input).toEqual({ x: 100, y: 0, zoom: 1.5 });
|
||||
});
|
||||
|
||||
it('gibt kein negatives Null zurück', () => {
|
||||
const c = clampCrop(tall, { x: -500, y: 0, zoom: 1 });
|
||||
expect(Object.is(c.x, 0)).toBe(true);
|
||||
expect(clampCrop(exact, { x: -5, y: -5, zoom: 1 })).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('panBy', () => {
|
||||
it('verschiebt um dx/dy', () => {
|
||||
const c = panBy(wide, { x: 0, y: 0, zoom: 1 }, 100, 40);
|
||||
expect(c.x).toBeCloseTo(100, 1);
|
||||
expect(c.y).toBeCloseTo(0, 1); // Breitbild: in y nichts zu verschieben
|
||||
const z = panBy(wide, { x: 10, y: 5, zoom: 2 }, -30, 20);
|
||||
expect(z).toEqual({ x: -20, y: 25, zoom: 2 });
|
||||
});
|
||||
|
||||
it('bleibt über den Rand hinaus begrenzt', () => {
|
||||
const c = panBy(wide, { x: 600, y: 0, zoom: 1 }, 5000, 0);
|
||||
expect(c.x).toBeCloseTo(685.5, 1);
|
||||
const d = panBy(wide, c, -99999, 0);
|
||||
expect(d.x).toBeCloseTo(-685.5, 1);
|
||||
});
|
||||
|
||||
it('verändert die Eingabe nicht', () => {
|
||||
const input = { x: 0, y: 0, zoom: 2 };
|
||||
panBy(wide, input, 50, 50);
|
||||
expect(input).toEqual({ x: 0, y: 0, zoom: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoomTo', () => {
|
||||
it('begrenzt auf 1 bis 4', () => {
|
||||
expect(zoomTo(wide, DEFAULT_CROP, 0.5).zoom).toBe(1);
|
||||
expect(zoomTo(wide, DEFAULT_CROP, 7).zoom).toBe(4);
|
||||
expect(zoomTo(wide, DEFAULT_CROP, 2).zoom).toBe(2);
|
||||
expect(zoomTo(wide, DEFAULT_CROP, NaN).zoom).toBe(1);
|
||||
});
|
||||
|
||||
it('hält den Bildpunkt in der Kartenmitte fest', () => {
|
||||
// Bildpunkt unter der Kartenmitte, vorher und nachher
|
||||
const at = (img: { w: number; h: number }, c: { x: number; y: number; zoom: number }) => {
|
||||
const r = imageRect(img, c);
|
||||
const k = coverScale(img.w, img.h) * c.zoom;
|
||||
return { u: (CARD_W / 2 - r.x) / k, v: (CARD_H / 2 - r.y) / k };
|
||||
};
|
||||
const start = { x: -200, y: 0, zoom: 1 };
|
||||
const before = at(wide, start);
|
||||
const z = zoomTo(wide, start, 2);
|
||||
expect(z.zoom).toBe(2);
|
||||
const after = at(wide, z);
|
||||
expect(after.u).toBeCloseTo(before.u, 1);
|
||||
expect(after.v).toBeCloseTo(before.v, 1);
|
||||
});
|
||||
|
||||
it('beim Herauszoomen bleibt der Ausschnitt gültig', () => {
|
||||
const z = zoomTo(wide, { x: 1371, y: 0, zoom: 3 }, 1);
|
||||
expect(z.zoom).toBe(1);
|
||||
expect(Math.abs(z.x)).toBeLessThanOrEqual(685.5 + 1e-6);
|
||||
const r = imageRect(wide, z);
|
||||
expect(r.x).toBeLessThanOrEqual(1e-6);
|
||||
expect(r.x + r.w).toBeGreaterThanOrEqual(CARD_W - 1e-6);
|
||||
});
|
||||
|
||||
it('verändert die Eingabe nicht', () => {
|
||||
const input = { x: 10, y: 0, zoom: 1 };
|
||||
zoomTo(wide, input, 3);
|
||||
expect(input).toEqual({ x: 10, y: 0, zoom: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Bildausschnitt: Cover-Fit plus Verschiebung/Zoom des Nutzers. Reine Mathematik, kein DOM.
|
||||
import { DEFAULT_CROP, MAX_ZOOM, MIN_ZOOM, type Crop } from './config';
|
||||
import { CARD_H, CARD_W, type Box } from './geometry';
|
||||
|
||||
interface ImgSize {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Maßstab, bei dem das Bild die Karte gerade ganz bedeckt. */
|
||||
export function coverScale(imgW: number, imgH: number): number {
|
||||
return Math.max(CARD_W / imgW, CARD_H / imgH);
|
||||
}
|
||||
|
||||
/** Gezeichnetes Rechteck des Bildes für einen Ausschnitt (begrenzt nicht selbst, vorher clampCrop). */
|
||||
export function imageRect(img: ImgSize, crop: Crop): Box {
|
||||
const s = coverScale(img.w, img.h) * crop.zoom;
|
||||
const w = img.w * s;
|
||||
const h = img.h * s;
|
||||
return { x: (CARD_W - w) / 2 + crop.x, y: (CARD_H - h) / 2 + crop.y, w, h };
|
||||
}
|
||||
|
||||
// Math.min/max können -0 liefern; das +0 macht daraus 0, damit toEqual/Object.is nicht stolpern.
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.min(Math.max(v, lo), hi) + 0;
|
||||
}
|
||||
|
||||
function finite(v: number, fallback: number): number {
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function clampZoom(zoom: number): number {
|
||||
return clamp(finite(zoom, DEFAULT_CROP.zoom), MIN_ZOOM, MAX_ZOOM);
|
||||
}
|
||||
|
||||
/** Zoom auf [1, 4], Versatz so, dass das Bild die Karte ganz bedeckt. Nicht endliche Zahlen -> Standard. */
|
||||
export function clampCrop(img: ImgSize, crop: Crop): Crop {
|
||||
const zoom = clampZoom(crop.zoom);
|
||||
const s = coverScale(img.w, img.h) * zoom;
|
||||
const maxX = Math.max(0, (img.w * s - CARD_W) / 2);
|
||||
const maxY = Math.max(0, (img.h * s - CARD_H) / 2);
|
||||
return {
|
||||
x: clamp(finite(crop.x, DEFAULT_CROP.x), -maxX, maxX),
|
||||
y: clamp(finite(crop.y, DEFAULT_CROP.y), -maxY, maxY),
|
||||
zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/** Verschiebt den Ausschnitt um (dx, dy) Karten-px, begrenzt. */
|
||||
export function panBy(img: ImgSize, crop: Crop, dx: number, dy: number): Crop {
|
||||
return clampCrop(img, { x: crop.x + dx, y: crop.y + dy, zoom: crop.zoom });
|
||||
}
|
||||
|
||||
/** Setzt den Zoom (1 bis 4) und hält dabei den Bildpunkt in der Kartenmitte fest. */
|
||||
export function zoomTo(img: ImgSize, crop: Crop, zoom: number): Crop {
|
||||
const cur = clampCrop(img, crop);
|
||||
const next = clampZoom(zoom);
|
||||
const k = next / cur.zoom;
|
||||
return clampCrop(img, { x: cur.x * k, y: cur.y * k, zoom: next });
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CARD_H,
|
||||
CARD_W,
|
||||
MARGIN,
|
||||
PREVIEW_RADIUS,
|
||||
WALLET_KEEP_CLEAR,
|
||||
fitBrand,
|
||||
fitNetwork,
|
||||
layoutFor,
|
||||
type Box,
|
||||
} from './geometry';
|
||||
import type { TemplateId } from './config';
|
||||
|
||||
const TEMPLATES: TemplateId[] = ['image', 'minimal', 'classic', 'signature'];
|
||||
|
||||
const intersects = (a: Box, b: Box) =>
|
||||
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
|
||||
const insideCanvas = (b: Box) =>
|
||||
b.x >= 0 && b.y >= 0 && b.x + b.w <= CARD_W && b.y + b.h <= CARD_H;
|
||||
|
||||
// Feste Worst-Case-Boxen der PLATINUM-Zeile (Text 360 px breit)
|
||||
const PLATINUM_BOX: Partial<Record<TemplateId, Box>> = {
|
||||
classic: { x: 1120, y: 77, w: 360, h: 58 },
|
||||
signature: { x: 60, y: 182, w: 360, h: 55 },
|
||||
};
|
||||
const NUMBER_BOX: Box = { x: 157, y: 572, w: 867, h: 78 };
|
||||
|
||||
describe('Konstanten', () => {
|
||||
it('Kartenmaße und Rand', () => {
|
||||
expect(CARD_W).toBe(1536);
|
||||
expect(CARD_H).toBe(969);
|
||||
expect(MARGIN).toBe(56);
|
||||
expect(PREVIEW_RADIUS).toBe(57);
|
||||
});
|
||||
|
||||
it('WALLET_KEEP_CLEAR liegt unten links', () => {
|
||||
expect(WALLET_KEEP_CLEAR).toEqual({ x: 0, y: 779, w: 620, h: 190 });
|
||||
expect(WALLET_KEEP_CLEAR.y + WALLET_KEEP_CLEAR.h).toBe(CARD_H);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitNetwork', () => {
|
||||
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => {
|
||||
const b = fitNetwork(330 / 109, 109);
|
||||
expect(b.x).toBeCloseTo(1150, 1);
|
||||
expect(b.y).toBeCloseTo(804, 1);
|
||||
expect(b.w).toBeCloseTo(330, 1);
|
||||
expect(b.h).toBeCloseTo(109, 1);
|
||||
});
|
||||
|
||||
it('sehr breite Logos werden auf 360 begrenzt', () => {
|
||||
const b = fitNetwork(6, 109);
|
||||
expect(b.w).toBeCloseTo(360, 1);
|
||||
expect(b.h).toBeCloseTo(60, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('reine Symbole dürfen bis 125 px hoch sein', () => {
|
||||
const b = fitNetwork(1, 125);
|
||||
expect(b.w).toBeCloseTo(125, 1);
|
||||
expect(b.h).toBeCloseTo(125, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis bleibt erhalten', () => {
|
||||
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
|
||||
const b = fitNetwork(aspect, 109);
|
||||
expect(b.w / b.h).toBeCloseTo(aspect, 1);
|
||||
expect(b.h).toBeLessThanOrEqual(109 + 1e-9);
|
||||
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitBrand', () => {
|
||||
it('quadratisches Logo: 100 × 100 links oben auf dem Rand', () => {
|
||||
expect(fitBrand(1)).toEqual({ x: 56, y: 56, w: 100, h: 100 });
|
||||
});
|
||||
|
||||
it('breites Logo wird auf 360 × 100 begrenzt', () => {
|
||||
const b = fitBrand(3.6);
|
||||
expect(b.x).toBe(56);
|
||||
expect(b.y).toBe(56);
|
||||
expect(b.w).toBeCloseTo(360, 1);
|
||||
expect(b.h).toBeCloseTo(100, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(416, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(156, 1);
|
||||
});
|
||||
|
||||
it('sehr breites Logo ist höchstens 360 breit, Höhe folgt dem Seitenverhältnis', () => {
|
||||
const b = fitBrand(8);
|
||||
expect(b.w).toBeCloseTo(360, 1);
|
||||
expect(b.h).toBeCloseTo(45, 1);
|
||||
});
|
||||
|
||||
it('maxH ist einstellbar', () => {
|
||||
const b = fitBrand(1, 60);
|
||||
expect(b.w).toBeCloseTo(60, 1);
|
||||
expect(b.h).toBeCloseTo(60, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutFor', () => {
|
||||
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => {
|
||||
const l = layoutFor('image');
|
||||
expect(l.network).toBe(false);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
});
|
||||
|
||||
it('minimal: nur Netzwerk', () => {
|
||||
const l = layoutFor('minimal');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
});
|
||||
|
||||
it('classic: alles', () => {
|
||||
const l = layoutFor('classic');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
|
||||
expect(l.chip).toEqual({
|
||||
x: 157, y: 326, w: 226, h: 205, r: 32,
|
||||
hLines: [383, 429, 474], vLines: [234, 307], padY: [383, 474],
|
||||
});
|
||||
expect(l.contactless).toEqual({
|
||||
path:
|
||||
'M548.75 379.89a98.21 98.21 0 0 1 0 98.21M527.14 390.7a76.61 76.61 0 0 1 0 76.61' +
|
||||
'M506.52 400.61a55.39 55.98 0 0 1 0 55.98M485.89 411.32a35.36 33.39 0 0 1 0 35.36',
|
||||
lineWidth: 11.8,
|
||||
box: { x: 480, y: 374, w: 88, h: 110 },
|
||||
});
|
||||
expect(l.number).toEqual({
|
||||
startX: 157, pitch: 45.6, baseline: 650, size: 72,
|
||||
bulletY: 624, bulletR: 8, text: '•••• •••• •••• 8888',
|
||||
});
|
||||
});
|
||||
|
||||
it('signature: Netzwerk, Marke und PLATINUM links', () => {
|
||||
const l = layoutFor('signature');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
|
||||
expect(l.chip).toBeUndefined();
|
||||
expect(l.contactless).toBeUndefined();
|
||||
expect(l.number).toBeUndefined();
|
||||
});
|
||||
|
||||
it('liefert unabhängige Kopien', () => {
|
||||
const a = layoutFor('classic');
|
||||
a.chip!.hLines.push(999);
|
||||
a.number!.text = 'x';
|
||||
const b = layoutFor('classic');
|
||||
expect(b.chip!.hLines).toEqual([383, 429, 474]);
|
||||
expect(b.number!.text).toBe('•••• •••• •••• 8888');
|
||||
});
|
||||
|
||||
it('Kontaktlinien des Chips liegen im Chip', () => {
|
||||
const c = layoutFor('classic').chip!;
|
||||
for (const y of c.hLines) expect(y > c.y && y < c.y + c.h).toBe(true);
|
||||
for (const x of c.vLines) expect(x > c.x && x < c.x + c.w).toBe(true);
|
||||
expect(c.padY[0]).toBe(c.hLines[0]);
|
||||
expect(c.padY[1]).toBe(c.hLines[c.hLines.length - 1]);
|
||||
});
|
||||
|
||||
it('die festen PLATINUM-Boxen passen zu den Layoutwerten', () => {
|
||||
for (const t of ['classic', 'signature'] as const) {
|
||||
const p = layoutFor(t).platinum!;
|
||||
const box = PLATINUM_BOX[t]!;
|
||||
if (p.align === 'right') expect(box.x + box.w).toBe(p.x);
|
||||
else expect(box.x).toBe(p.x);
|
||||
expect(p.baseline > box.y && p.baseline <= box.y + box.h).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('die Nummernzeile passt in die feste Box', () => {
|
||||
const n = layoutFor('classic').number!;
|
||||
expect(n.startX).toBe(NUMBER_BOX.x);
|
||||
expect(n.baseline).toBe(NUMBER_BOX.y + NUMBER_BOX.h);
|
||||
expect(n.startX + n.text.length * n.pitch).toBeLessThanOrEqual(NUMBER_BOX.x + NUMBER_BOX.w);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (t) => {
|
||||
const l = layoutFor(t);
|
||||
const boxes: Record<string, Box> = {};
|
||||
if (l.chip) boxes.chip = { x: l.chip.x, y: l.chip.y, w: l.chip.w, h: l.chip.h };
|
||||
if (l.contactless) boxes.contactless = l.contactless.box;
|
||||
if (l.number) boxes.number = NUMBER_BOX;
|
||||
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
|
||||
if (l.network) {
|
||||
boxes.networkSymbol = fitNetwork(1, 125);
|
||||
boxes.networkWide = fitNetwork(6, 109);
|
||||
}
|
||||
if (l.brand) boxes.brand = fitBrand(3.6);
|
||||
|
||||
it('hat für jedes Element eine Prüfbox', () => {
|
||||
expect(Object.keys(boxes).length).toBe(
|
||||
{ image: 0, minimal: 2, classic: 7, signature: 4 }[t],
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, box] of Object.entries(boxes)) {
|
||||
it(`${name} liegt im Canvas und schneidet WALLET_KEEP_CLEAR nicht`, () => {
|
||||
expect(insideCanvas(box)).toBe(true);
|
||||
expect(intersects(box, WALLET_KEEP_CLEAR)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Kartengeometrie (Canvas 1536 x 969, Rand 56 px). Reine Zahlen, kein DOM.
|
||||
import type { TemplateId } from './config';
|
||||
|
||||
export const CARD_W = 1536;
|
||||
export const CARD_H = 969;
|
||||
export const MARGIN = 56;
|
||||
/** Nur für die Vorschau (CSS), nie im Export. */
|
||||
export const PREVIEW_RADIUS = 57;
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */
|
||||
export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
|
||||
|
||||
const NETWORK_MAX_W = 360;
|
||||
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913
|
||||
const BRAND_MAX_W = 360;
|
||||
|
||||
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitNetwork(aspect: number, maxH: number): Box {
|
||||
const w = Math.min(NETWORK_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
return { x: NETWORK_RIGHT - w, y: NETWORK_BOTTOM - h, w, h };
|
||||
}
|
||||
|
||||
/** AirCard-/Apple-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
const w = Math.min(BRAND_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
return { x: MARGIN, y: MARGIN, w, h };
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
network: boolean;
|
||||
brand: boolean;
|
||||
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
|
||||
chip?: {
|
||||
x: number; y: number; w: number; h: number; r: number;
|
||||
hLines: number[]; vLines: number[]; padY: [number, number];
|
||||
};
|
||||
contactless?: { path: string; lineWidth: number; box: Box };
|
||||
number?: {
|
||||
startX: number; pitch: number; baseline: number; size: number;
|
||||
bulletY: number; bulletR: number; text: string;
|
||||
};
|
||||
}
|
||||
|
||||
const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
image: { network: false, brand: false },
|
||||
minimal: { network: true, brand: false },
|
||||
classic: {
|
||||
network: true,
|
||||
brand: true,
|
||||
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
|
||||
chip: {
|
||||
x: 157, y: 326, w: 226, h: 205, r: 32,
|
||||
hLines: [383, 429, 474], vLines: [234, 307], padY: [383, 474],
|
||||
},
|
||||
contactless: {
|
||||
path:
|
||||
'M548.75 379.89a98.21 98.21 0 0 1 0 98.21' +
|
||||
'M527.14 390.7a76.61 76.61 0 0 1 0 76.61' +
|
||||
'M506.52 400.61a55.39 55.98 0 0 1 0 55.98' +
|
||||
'M485.89 411.32a35.36 33.39 0 0 1 0 35.36',
|
||||
lineWidth: 11.8,
|
||||
box: { x: 480, y: 374, w: 88, h: 110 },
|
||||
},
|
||||
number: {
|
||||
startX: 157, pitch: 45.6, baseline: 650, size: 72,
|
||||
bulletY: 624, bulletR: 8, text: '•••• •••• •••• 8888',
|
||||
},
|
||||
},
|
||||
signature: {
|
||||
network: true,
|
||||
brand: true,
|
||||
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
|
||||
},
|
||||
};
|
||||
|
||||
/** Was eine Vorlage zeichnet und wo. Liefert eine Kopie, die der Aufrufer verändern darf. */
|
||||
export function layoutFor(t: TemplateId): Layout {
|
||||
return structuredClone(LAYOUTS[t]);
|
||||
}
|
||||
Reference in new issue
Block a user