EventBuzz_Console/src/app/components/panels/badgeLayout.ts

469 lines
15 KiB
TypeScript

/**
* Badge layout types and helpers — shared between preview and API payload.
*/
export type BadgeFieldId = "name" | "title" | "org" | "badge_type" | "qr" | "photo";
export type BadgeTextAlign = "left" | "center" | "right";
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
export type BadgeBackgroundImageLayout = {
x: number;
y: number;
w: number;
h: number;
};
/** Preview selection id for the optional background image layer. */
export const BADGE_BACKGROUND_IMAGE_ID = "__badge_bg__";
const BG_IMAGE_MIN_SIZE = 10;
const BG_IMAGE_MAX_SIZE = 500;
const BG_IMAGE_MIN_POS = -400;
const BG_IMAGE_MAX_POS = 400;
export function defaultBackgroundImageLayout(): BadgeBackgroundImageLayout {
return { x: 0, y: 0, w: 100, h: 100 };
}
export function parseBackgroundImageLayout(raw: unknown): BadgeBackgroundImageLayout | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
if (
typeof o.x !== "number" ||
typeof o.y !== "number" ||
typeof o.w !== "number" ||
typeof o.h !== "number"
) {
return null;
}
return applyBackgroundImageLayoutPatch({ x: o.x, y: o.y, w: o.w, h: o.h });
}
/** Allow bleed outside the badge — only soft sanity limits for save/drag. */
export function applyBackgroundImageLayoutPatch(
layout: BadgeBackgroundImageLayout,
patch: Partial<BadgeBackgroundImageLayout> = {},
): BadgeBackgroundImageLayout {
const w = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.w ?? layout.w));
const h = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.h ?? layout.h));
const x = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.x ?? layout.x));
const y = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.y ?? layout.y));
return { x, y, w, h };
}
/** @deprecated Use applyBackgroundImageLayoutPatch */
export function clampBackgroundImageLayout(
layout: BadgeBackgroundImageLayout,
patch: Partial<BadgeBackgroundImageLayout> = {},
): BadgeBackgroundImageLayout {
return applyBackgroundImageLayoutPatch(layout, patch);
}
export type BadgeLayoutElement = {
id: string;
kind: BadgeLayoutKind;
field?: BadgeFieldId;
text?: string;
imageUrl?: string;
/** Uploaded image width ÷ height */
imageAspectRatio?: number;
x: number;
y: number;
w?: number;
h?: number;
fontSizePct?: number;
fontFamily?: string;
color?: string;
fontWeight?: "normal" | "bold";
/** Default true — span full badge width with centered text */
fullWidthCenter?: boolean;
/** Default true — reduce font size when text overflows */
shrinkToFit?: boolean;
textAlign?: BadgeTextAlign;
};
export const DEFAULT_TEXT_LAYOUT = {
fullWidthCenter: true,
shrinkToFit: true,
textAlign: "center" as BadgeTextAlign,
x: 0,
w: 100,
};
/** Treat undefined as true for backward-compatible defaults */
export function usesFullWidthCenter(el: BadgeLayoutElement): boolean {
return el.fullWidthCenter !== false;
}
export function usesShrinkToFit(el: BadgeLayoutElement): boolean {
return el.shrinkToFit !== false;
}
export function textAlignFor(el: BadgeLayoutElement): BadgeTextAlign {
if (usesFullWidthCenter(el)) return el.textAlign ?? "center";
return el.textAlign ?? "left";
}
export function isResizableElement(el: BadgeLayoutElement): boolean {
return el.field === "qr" || el.field === "photo" || el.kind === "custom_image";
}
export function isSquareResizable(el: BadgeLayoutElement): boolean {
return el.field === "qr";
}
export function elementSizePct(el: BadgeLayoutElement): number {
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
}
/** Square elements sized by width % of badge — height as % of badge height */
export function squareElementHeightPct(widthPct: number, badgeAspectRatio: number): number {
const ar = badgeAspectRatio > 0 ? badgeAspectRatio : 1;
return widthPct * ar;
}
export function clampElementSize(el: BadgeLayoutElement, w: number): number {
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
}
export function clampHeightPct(h: number): number {
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, h));
}
/** Height % of badge for a given width % preserving uploaded image aspect */
export function heightPctForImageWidth(
w: number,
imageAspectRatio: number,
badgeAspectRatio: number,
): number {
const ar = imageAspectRatio > 0 ? imageAspectRatio : 1;
return clampHeightPct((w * badgeAspectRatio) / ar);
}
export function loadImageAspectRatio(url: string): Promise<number> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1);
img.onerror = () => resolve(1);
img.src = url;
});
}
export function readFileImageAspectRatio(file: File): Promise<number> {
const blob = URL.createObjectURL(file);
return loadImageAspectRatio(blob).finally(() => URL.revokeObjectURL(blob));
}
/** Apply new width/height and re-clamp position so element stays on badge */
export function applyElementSize(
el: BadgeLayoutElement,
w: number,
badgeAspectRatio: number,
): Partial<BadgeLayoutElement> {
const size = clampElementSize(el, w);
const patch: Partial<BadgeLayoutElement> = { w: size };
if (isSquareResizable(el) || el.field === "photo") {
patch.h = squareElementHeightPct(size, badgeAspectRatio);
} else if (el.kind === "custom_image") {
const ar = el.imageAspectRatio ?? 1;
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
}
const merged = { ...el, ...patch };
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) };
}
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } {
if (el.field === "qr" || el.field === "photo") {
const s = elementSizePct(el);
return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) };
}
if (el.kind === "custom_image") {
return { w: el.w ?? 25, h: el.h ?? 25 };
}
if (isTextElement(el)) {
if (usesFullWidthCenter(el)) {
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
}
return { w: el.w ?? 72, h: Math.max(4, (el.fontSizePct ?? 5) * 2.5) };
}
return { w: 0, h: 0 };
}
export function clampElementPosition(
el: BadgeLayoutElement,
x: number,
y: number,
badgeAspectRatio = 1,
): { x: number; y: number } {
const bounds = elementBoundsPct(el, badgeAspectRatio);
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
let cx = Math.min(maxX, Math.max(0, x));
const cy = Math.min(maxY, Math.max(0, y));
if (isTextElement(el) && usesFullWidthCenter(el)) cx = 0;
return { x: cx, y: cy };
}
export const BADGE_FONT_FAMILIES = [
{ id: "Inter", label: "Inter", css: "var(--font-inter), Inter, sans-serif" },
{ id: "Noto Sans", label: "Noto Sans", css: "var(--font-noto-sans), 'Noto Sans', sans-serif" },
{ id: "Arial", label: "Arial", css: "Arial, sans-serif" },
{ id: "Georgia", label: "Georgia", css: "Georgia, serif" },
{ id: "Times New Roman", label: "Times New Roman", css: "'Times New Roman', Times, serif" },
] as const;
export const DEFAULT_TEXT_COLOR = "#0f172a";
export const MAX_CUSTOM_TEXT_FIELDS = 5;
export const MAX_CUSTOM_IMAGE_FIELDS = 5;
export const RESIZABLE_SIZE_MIN = 8;
export const RESIZABLE_SIZE_MAX = 65;
const PRESETS_MM: Record<string, { w: number; h: number }> = {
a6: { w: 105, h: 148 },
a7: { w: 74, h: 105 },
"4x3in": { w: 101.6, h: 76.2 },
"4x6in": { w: 101.6, h: 152.4 },
};
export function getBadgeAspectRatio(
sizePreset: "a6" | "a7" | "4x3in" | "4x6in" | "custom",
orientation: "portrait" | "landscape",
customW?: string,
customH?: string,
): number {
let w: number;
let h: number;
if (sizePreset === "custom") {
w = parseFloat(customW || "105") || 105;
h = parseFloat(customH || "148") || 148;
} else {
const p = PRESETS_MM[sizePreset] || PRESETS_MM.a6;
w = p.w;
h = p.h;
}
if (orientation === "landscape") [w, h] = [h, w];
return w / h;
}
export function contrastTextColor(hex: string): string {
const h = (hex || "#000000").replace("#", "");
const r = parseInt(h.slice(0, 2), 16) || 0;
const g = parseInt(h.slice(2, 4), 16) || 0;
const b = parseInt(h.slice(4, 6), 16) || 0;
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55 ? "#0f172a" : "#ffffff";
}
function textDefaults(bgColor: string, bgMode: "color" | "image") {
const color = bgMode === "color" ? contrastTextColor(bgColor) : DEFAULT_TEXT_COLOR;
return {
color,
fontFamily: "Inter",
fontWeight: "normal" as const,
...DEFAULT_TEXT_LAYOUT,
};
}
function defaultElementForField(fieldId: BadgeFieldId, bgColor: string, bgMode: "color" | "image"): BadgeLayoutElement {
const base = { kind: "field" as const, field: fieldId, id: fieldId, ...textDefaults(bgColor, bgMode) };
switch (fieldId) {
case "name":
return { ...base, y: 8, fontSizePct: 7, fontWeight: "bold" };
case "title":
return { ...base, y: 22, fontSizePct: 4.5 };
case "org":
return { ...base, y: 32, fontSizePct: 4 };
case "badge_type":
return { ...base, y: 44, fontSizePct: 3.5 };
case "photo":
return { kind: "field", field: fieldId, id: fieldId, x: 74, y: 6, w: 18, h: 18 };
case "qr":
return { kind: "field", field: fieldId, id: fieldId, x: 36, y: 68, w: 28, h: 28 };
default:
return { ...base, y: 8, fontSizePct: 5 };
}
}
export function defaultLayoutForFields(
fieldIds: string[],
bgColor: string,
bgMode: "color" | "image",
): BadgeLayoutElement[] {
return fieldIds
.filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id))
.map((id) => defaultElementForField(id, bgColor, bgMode));
}
export function defaultCustomTextElement(
id: string,
text: string,
bgColor: string,
bgMode: "color" | "image",
index: number,
): BadgeLayoutElement {
return {
id,
kind: "custom_text",
text,
y: 55 + index * 8,
fontSizePct: 4,
fontWeight: "normal",
...textDefaults(bgColor, bgMode),
};
}
export function newCustomTextId(): string {
const hex = Math.random().toString(16).slice(2, 10);
return `custom_${hex}`;
}
export function newCustomImageId(): string {
const hex = Math.random().toString(16).slice(2, 10);
return `cimg_${hex}`;
}
export function buildCustomImageElement(
id: string,
imageUrl: string,
index: number,
badgeAspectRatio: number,
imageAspectRatio: number,
): BadgeLayoutElement {
const w = 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
return {
id,
kind: "custom_image",
imageUrl,
imageAspectRatio,
x: 10 + index * 4,
y: 50 + index * 6,
w,
h,
};
}
export function imageUrlForElement(
el: BadgeLayoutElement,
customImages: { id: string; imageUrl: string }[],
): string {
return (el.imageUrl ?? customImages.find((c) => c.id === el.id)?.imageUrl ?? "").trim();
}
export function fontCss(family?: string): string {
const f = BADGE_FONT_FAMILIES.find((x) => x.id === family);
return f?.css ?? BADGE_FONT_FAMILIES[0].css;
}
export type BadgePreviewSample = {
name: string;
title: string;
org: string;
badgeType: string;
};
export function samplePreviewData(badgeTypeLabel: string): BadgePreviewSample {
return {
name: "Jane Doe",
title: "Keynote Speaker",
org: "Acme Inc.",
badgeType: badgeTypeLabel.trim() || "Attendee",
};
}
const PREVIEW_AVATAR_COLORS = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#be185d", "#0d9488", "#b45309"];
export function previewInitialsFromName(name: string): string {
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase();
return (parts[0]?.charAt(0) || "J").toUpperCase();
}
export function previewAvatarColor(seed: string): string {
let n = 0;
for (let i = 0; i < seed.length; i++) n += seed.charCodeAt(i);
return PREVIEW_AVATAR_COLORS[n % PREVIEW_AVATAR_COLORS.length];
}
export function previewTextForElement(
el: BadgeLayoutElement,
sample: BadgePreviewSample,
): string {
if (el.kind === "custom_text") return el.text?.trim() || "Your text";
switch (el.field) {
case "name":
return sample.name;
case "title":
return sample.title;
case "org":
return sample.org;
case "badge_type":
return sample.badgeType;
default:
return "";
}
}
export function isTextElement(el: BadgeLayoutElement): boolean {
if (el.kind === "custom_text") return true;
return el.field === "name" || el.field === "title" || el.field === "org" || el.field === "badge_type";
}
export function syncLayoutWithFields(
prev: BadgeLayoutElement[],
enabledFields: string[],
customTexts: { id: string; text: string }[],
customImages: { id: string; imageUrl: string }[],
bgColor: string,
bgMode: "color" | "image",
badgeAspectRatio: number,
): BadgeLayoutElement[] {
const fieldSet = new Set(enabledFields);
const customTextMap = new Map(customTexts.map((c) => [c.id, c.text]));
const customImageMap = new Map(customImages.map((c) => [c.id, c.imageUrl]));
const keptFields = prev.filter((el) => el.kind === "field" && el.field && fieldSet.has(el.field));
const keptCustomText = prev.filter((el) => el.kind === "custom_text" && customTextMap.has(el.id));
const keptCustomImage = prev.filter((el) => el.kind === "custom_image" && customImageMap.has(el.id));
const existingFieldIds = new Set(keptFields.map((e) => e.field));
const addedFields = enabledFields
.filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id))
.filter((id) => !existingFieldIds.has(id))
.map((id) => defaultElementForField(id, bgColor, bgMode));
const existingTextIds = new Set(keptCustomText.map((e) => e.id));
const addedCustomText = customTexts
.filter((c) => !existingTextIds.has(c.id))
.map((c, i) => defaultCustomTextElement(c.id, c.text, bgColor, bgMode, i));
const existingImageIds = new Set(keptCustomImage.map((e) => e.id));
const addedCustomImage = customImages
.filter((c) => !existingImageIds.has(c.id))
.map((c, i) => buildCustomImageElement(c.id, c.imageUrl, i, badgeAspectRatio, 1));
const mergedCustomText = [
...keptCustomText.map((el) => ({ ...el, text: customTextMap.get(el.id) ?? el.text })),
...addedCustomText,
];
const mergedCustomImage = [
...keptCustomImage.map((el) => {
const imageUrl = customImageMap.get(el.id) ?? el.imageUrl;
const ar = el.imageAspectRatio ?? 1;
const w = el.w ?? 25;
return {
...el,
imageUrl,
h: heightPctForImageWidth(w, ar, badgeAspectRatio),
};
}),
...addedCustomImage,
];
return [...keptFields, ...addedFields, ...mergedCustomText, ...mergedCustomImage];
}