556 lines
23 KiB
TypeScript
556 lines
23 KiB
TypeScript
// CardPreview — the single SVG rendering implementation for the QSL card.
|
|
// Proposals, the live editor, thumbnails and final rasterization ALL go
|
|
// through this component so the output can never drift from the preview.
|
|
// Style presets are layer-stack recipes (see internal/qslcard/presets.go and
|
|
// section 4 of the design spec); the gel stack renders the text 7 times,
|
|
// back to front: halo, drop shadow, outline, bevel base, bevel light edge,
|
|
// gradient face, wavy gloss band clipped to the glyphs.
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes';
|
|
import { QSO_FIELD_LABELS } from './qslTypes';
|
|
import type { CardAssets } from './qslAssets';
|
|
import { FONT_WEIGHTS } from './qslAssets';
|
|
import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx';
|
|
|
|
// An editor selection: an element index or the QSO box.
|
|
export type CardSelection = number | 'box' | null;
|
|
|
|
// Fraction of the font size between a glyph's em-box top and its cap line.
|
|
// Used to make a text element's y align to the visible letter tops.
|
|
const CAP_INSET = 0.18;
|
|
|
|
interface Props {
|
|
model: RenderModel;
|
|
assets: CardAssets;
|
|
width: number; // display width in CSS px
|
|
selected?: CardSelection;
|
|
onSelect?: (sel: CardSelection) => void;
|
|
onMove?: (sel: Exclude<CardSelection, null>, x: number, y: number) => void;
|
|
svgRef?: (el: SVGSVGElement | null) => void;
|
|
}
|
|
|
|
// Fallbacks mirroring the preset defaults in presets.go, applied when a
|
|
// document omits a knob the stack needs.
|
|
const GOLD = ['#FFD83A', '#FFC312', '#EE9400'];
|
|
const DEF_SHINE = { coverage: 0.5, opacity: 0.95 };
|
|
const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 };
|
|
const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 };
|
|
const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' };
|
|
|
|
// fontSpec maps a template font name to a concrete family + weight.
|
|
// "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed.
|
|
function fontSpec(font?: string): { family: string; weight: number | 'normal' } {
|
|
if (!font || font === 'system-bold-sans') {
|
|
return { family: "'Segoe UI', Arial, sans-serif", weight: 800 };
|
|
}
|
|
return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' };
|
|
}
|
|
|
|
// Presets whose call text is rendered with the ported canvas FX (the only way
|
|
// to get the exact glossy/western look). Everything else stays plain SVG text.
|
|
const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']);
|
|
const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']);
|
|
function fxKindFor(preset?: string): TextFxKind | null {
|
|
if (preset && FX_GLOSSY.has(preset)) return 'glossy';
|
|
if (preset && FX_WESTERN.has(preset)) return 'western';
|
|
return null;
|
|
}
|
|
function buildFxParams(e: CardElement): TextFxParams | null {
|
|
const kind = fxKindFor(e.style_preset);
|
|
if (!kind || !e.size) return null;
|
|
const fs = fontSpec(e.font);
|
|
const sp = e.style_params ?? {};
|
|
const fx = sp.fx ?? {};
|
|
const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']);
|
|
const silver = e.style_preset?.includes('silver');
|
|
return {
|
|
kind,
|
|
text: e.text ?? '',
|
|
weight: String(fs.weight === 'normal' ? 400 : fs.weight),
|
|
family: fs.family,
|
|
size: e.size,
|
|
space: e.size * (kind === 'western' ? 0.08 : 0.04),
|
|
cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0],
|
|
// Dark inter-letter edge + rim — from the chosen colour palette, else the
|
|
// default near-black edge (the navy outline adaptStyle sets is for the old
|
|
// SVG stack, not this look).
|
|
cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'),
|
|
cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'),
|
|
// Per-call overrides from the editor (undefined → renderer default).
|
|
plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss,
|
|
glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b,
|
|
depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge,
|
|
bevel: fx.bevel, seed: fx.seed,
|
|
};
|
|
}
|
|
// Stable signature of an element's FX-relevant fields, so dragging (x/y only)
|
|
// doesn't trigger an expensive re-render of the canvas text.
|
|
function fxSignature(e: CardElement): string {
|
|
if (!fxKindFor(e.style_preset)) return '';
|
|
return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|');
|
|
}
|
|
|
|
function elementTransform(e: CardElement): string {
|
|
const r = e.rotate ? ` rotate(${e.rotate})` : '';
|
|
return `translate(${e.x} ${e.y})${r}`;
|
|
}
|
|
|
|
// Shared <text> layer of a style stack. All layers must carry identical
|
|
// glyph geometry — only paint/transform/filter differ.
|
|
function Layer({
|
|
e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder,
|
|
}: {
|
|
e: CardElement;
|
|
content: string;
|
|
fill: string;
|
|
stroke?: string;
|
|
strokeWidth?: number;
|
|
transform?: string;
|
|
filter?: string;
|
|
opacity?: number;
|
|
paintOrder?: 'stroke';
|
|
}) {
|
|
const f = fontSpec(e.font);
|
|
return (
|
|
<text
|
|
x={0}
|
|
y={0}
|
|
fontFamily={f.family}
|
|
fontWeight={f.weight}
|
|
fontSize={e.size}
|
|
dominantBaseline="text-before-edge"
|
|
fill={fill}
|
|
stroke={stroke}
|
|
strokeWidth={strokeWidth}
|
|
strokeLinejoin="round"
|
|
paintOrder={paintOrder}
|
|
transform={transform}
|
|
filter={filter}
|
|
opacity={opacity}
|
|
style={{ userSelect: 'none' }}
|
|
>
|
|
{content}
|
|
</text>
|
|
);
|
|
}
|
|
|
|
// glossWavePath builds the gel highlight band: full glyph width, bottom
|
|
// edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period
|
|
// ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line
|
|
// is what produces the "gel" look; the band is clipped to the glyphs.
|
|
function glossWavePath(e: CardElement, content: string, coverage: number): string {
|
|
const size = e.size ?? 100;
|
|
const w = 0.78 * size * Math.max(content.length, 1);
|
|
const capTop = 0.16 * size; // cap height starts below the em-box top
|
|
const bottom = capTop + coverage * 0.72 * size;
|
|
const amp = 0.045 * size;
|
|
const period = 0.25 * size;
|
|
let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`;
|
|
let i = 0;
|
|
for (let x = w; x > 0.1; x -= period, i++) {
|
|
const x2 = Math.max(0, x - period);
|
|
const mid = (x + x2) / 2;
|
|
const dy = i % 2 === 0 ? amp : -amp;
|
|
d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`;
|
|
}
|
|
return d + ' Z';
|
|
}
|
|
|
|
// TextStack renders one text element through its style preset.
|
|
function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) {
|
|
const p: StyleParams = e.style_params ?? {};
|
|
const preset = e.style_preset ?? 'flat_modern';
|
|
const ow = p.outline_width ?? 0;
|
|
|
|
if (preset === 'flat_modern') {
|
|
return <Layer e={e} content={content} fill={p.color ?? '#ffffff'} />;
|
|
}
|
|
if (preset === 'script_white' || preset === 'outlined_white') {
|
|
return (
|
|
<>
|
|
{p.shadow && (
|
|
<Layer
|
|
e={e} content={content} fill={p.shadow.color} stroke={p.shadow.color}
|
|
strokeWidth={2} transform={`translate(${p.shadow.dx} ${p.shadow.dy})`}
|
|
filter={`url(#qsl-blur-sh-${idx})`} opacity={p.shadow.opacity}
|
|
/>
|
|
)}
|
|
<Layer
|
|
e={e} content={content} fill={p.color ?? '#ffffff'}
|
|
stroke={p.outline_color ?? '#22364e'} strokeWidth={ow || 2} paintOrder="stroke"
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Gel family + classic white outline: the layered stack.
|
|
const gradient = p.gradient ?? GOLD;
|
|
const shadow = p.shadow ?? DEF_SHADOW;
|
|
const outline = p.outline_color ?? '#2a3f5c';
|
|
const outlineW = ow || 9;
|
|
const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
|
|
const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
|
|
const halo = p.halo ?? DEF_HALO;
|
|
const bevel = p.bevel_offset ?? DEF_BEVEL;
|
|
const shine = p.shine ?? DEF_SHINE;
|
|
const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined;
|
|
return (
|
|
<>
|
|
{isGel && (
|
|
<Layer
|
|
e={e} content={content} fill="none" stroke={halo.color}
|
|
strokeWidth={outlineW * 2.5} filter={`url(#qsl-blur-halo-${idx})`} opacity={halo.opacity}
|
|
/>
|
|
)}
|
|
<Layer
|
|
e={e} content={content} fill={shadow.color} stroke={shadow.color} strokeWidth={2}
|
|
transform={`translate(${shadow.dx} ${shadow.dy})`}
|
|
filter={`url(#qsl-blur-sh-${idx})`} opacity={shadow.opacity}
|
|
/>
|
|
<Layer
|
|
e={e} content={content} fill={outline} stroke={outline}
|
|
strokeWidth={outlineW} paintOrder="stroke"
|
|
/>
|
|
{isGel && <Layer e={e} content={content} fill={bevel.dark} />}
|
|
{isGel && (
|
|
<Layer
|
|
e={e} content={content} fill={bevel.light}
|
|
transform={`translate(${2 * bevel.dx} ${2 * bevel.dy})`}
|
|
/>
|
|
)}
|
|
<Layer e={e} content={content} fill={`url(#qsl-grad-${idx})`} transform={faceT} />
|
|
{isGel && (
|
|
<path
|
|
d={glossWavePath(e, content, shine.coverage)}
|
|
fill={`url(#qsl-gloss-${idx})`}
|
|
clipPath={`url(#qsl-clip-${idx})`}
|
|
transform={faceT}
|
|
opacity={shine.opacity}
|
|
/>
|
|
)}
|
|
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
|
|
{isGrunge && (
|
|
<rect
|
|
x={0} y={0}
|
|
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
|
|
height={(e.size ?? 0) * 1.05}
|
|
fill="#241405"
|
|
filter={`url(#qsl-grunge-${idx})`}
|
|
clipPath={`url(#qsl-clip-${idx})`}
|
|
transform={faceT}
|
|
opacity={0.6}
|
|
/>
|
|
)}
|
|
{/* defs that belong to this stack (unique ids per element index) */}
|
|
<defs>
|
|
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
|
{gradient.map((c, i) => (
|
|
<stop key={i} offset={`${(i / Math.max(gradient.length - 1, 1)) * 100}%`} stopColor={c} />
|
|
))}
|
|
</linearGradient>
|
|
{isGel && (
|
|
<>
|
|
<linearGradient id={`qsl-gloss-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
|
|
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.05" />
|
|
</linearGradient>
|
|
<clipPath id={`qsl-clip-${idx}`}>
|
|
<text
|
|
x={0} y={0} fontFamily={fontSpec(e.font).family} fontWeight={fontSpec(e.font).weight}
|
|
fontSize={e.size} dominantBaseline="text-before-edge"
|
|
>
|
|
{content}
|
|
</text>
|
|
</clipPath>
|
|
<filter id={`qsl-blur-halo-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
|
<feGaussianBlur stdDeviation={halo.blur} />
|
|
</filter>
|
|
</>
|
|
)}
|
|
{isGrunge && (
|
|
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
|
|
{/* sparse dark specks: fractal noise → threshold into the alpha */}
|
|
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
|
|
<feColorMatrix in="n" type="matrix"
|
|
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
|
|
</filter>
|
|
)}
|
|
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
|
<feGaussianBlur stdDeviation={shadow.blur} />
|
|
</filter>
|
|
</defs>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// QSO box field column weights — the date string is far wider than the rest,
|
|
// so equal columns let it spill into the next field. Wider weight = more room.
|
|
const QSO_FIELD_WEIGHT: Record<string, number> = {
|
|
qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
|
|
};
|
|
|
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
|
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
|
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
|
const avail = box.w - 56;
|
|
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
|
let cursor = 28;
|
|
const cols = box.fields.map((f) => {
|
|
const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
|
|
const col = { f, x: cursor, w };
|
|
cursor += w;
|
|
return col;
|
|
});
|
|
return (
|
|
<>
|
|
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
|
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
|
{box.title}
|
|
</text>
|
|
{cols.map(({ f, x }) => (
|
|
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
|
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
|
</text>
|
|
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
|
{values[f] ?? ''}
|
|
</text>
|
|
</g>
|
|
))}
|
|
{box.footer && (
|
|
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
|
fontFamily="'Segoe UI', Arial, sans-serif">
|
|
{box.footer}
|
|
</text>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) {
|
|
const { template: t, qso_fields } = model;
|
|
const card = t.card;
|
|
const scale = card.w / width;
|
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
|
const groupRefs = useRef(new Map<string, SVGGElement>());
|
|
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
|
|
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
|
|
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
// Embedded fonts may still be loading when the card first mounts; the canvas
|
|
// FX needs them for correct metrics, so (re)render once they're ready.
|
|
const [fontsReady, setFontsReady] = useState(false);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve();
|
|
void ready.then(() => { if (alive) setFontsReady(true); });
|
|
return () => { alive = false; };
|
|
}, [assets]);
|
|
|
|
// Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields
|
|
// so dragging doesn't regenerate it. Maps element index → PNG + size.
|
|
const fxSig = t.elements.map(fxSignature).join(';');
|
|
const fxImages = useMemo(() => {
|
|
const map = new Map<number, TextFxResult>();
|
|
if (!fontsReady) return map;
|
|
t.elements.forEach((e, idx) => {
|
|
const params = buildFxParams(e);
|
|
if (!params) return;
|
|
try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ }
|
|
});
|
|
return map;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [fxSig, fontsReady]);
|
|
|
|
// Measure the selected group for the dashed selection outline. For plain SVG
|
|
// text we use the glyphs' real ink metrics — getBBox would span the font's
|
|
// full ascent/descent. FX call images fall through to getBBox (already tight).
|
|
useEffect(() => {
|
|
if (selected === null || selected === undefined) { setSelBox(null); return; }
|
|
const key = String(selected);
|
|
const g = groupRefs.current.get(key);
|
|
if (!g) { setSelBox(null); return; }
|
|
const transform = g.getAttribute('transform') ?? '';
|
|
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
|
|
const isFx = el ? !!fxKindFor(el.style_preset) : false;
|
|
if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
|
|
const cv = (measureCanvas.current ??= document.createElement('canvas'));
|
|
const ctx = cv.getContext('2d');
|
|
if (ctx) {
|
|
const f = fontSpec(el.font);
|
|
ctx.font = `${f.weight} ${el.size}px ${f.family}`;
|
|
const m = ctx.measureText(el.text ?? '');
|
|
const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
|
|
const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
|
|
const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
|
|
const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
|
|
const left = -(m.actualBoundingBoxLeft ?? 0);
|
|
const right = m.actualBoundingBoxRight ?? m.width;
|
|
setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
|
|
return;
|
|
}
|
|
}
|
|
const b = g.getBBox();
|
|
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
|
|
}, [selected, model, assets, width, t.elements, fxImages]);
|
|
|
|
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
|
|
(ev: React.PointerEvent<SVGGElement>) => {
|
|
onSelect?.(sel);
|
|
if (!onMove) return;
|
|
ev.stopPropagation();
|
|
drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy };
|
|
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
|
|
};
|
|
|
|
const onPointerMove = (ev: React.PointerEvent<SVGSVGElement>) => {
|
|
const d = drag.current;
|
|
if (!d || !onMove) return;
|
|
const x = Math.round(d.ox + (ev.clientX - d.sx) * scale);
|
|
const y = Math.round(d.oy + (ev.clientY - d.sy) * scale);
|
|
onMove(d.sel, x, y);
|
|
};
|
|
const endDrag = () => { drag.current = null; };
|
|
|
|
const hero = assets.photos[t.hero.photo];
|
|
const crop = t.hero.crop;
|
|
|
|
const setGroupRef = (key: string) => (el: SVGGElement | null) => {
|
|
if (el) groupRefs.current.set(key, el);
|
|
else groupRefs.current.delete(key);
|
|
};
|
|
|
|
return (
|
|
<svg
|
|
ref={(el) => { rootRef.current = el; svgRef?.(el); }}
|
|
viewBox={`0 0 ${card.w} ${card.h}`}
|
|
width={width}
|
|
height={Math.round(width * (card.h / card.w))}
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
style={{ display: 'block', borderRadius: 4 }}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={endDrag}
|
|
onPointerLeave={endDrag}
|
|
onPointerDown={() => onSelect?.(null)}
|
|
>
|
|
<defs>
|
|
{/* Fonts must live INSIDE the SVG so rasterization (SVG → <img> →
|
|
canvas) sees them; document styles don't apply there. */}
|
|
<style>{assets.fontCss}</style>
|
|
<filter id="qsl-insert-shadow" x="-30%" y="-30%" width="160%" height="160%">
|
|
<feDropShadow dx="6" dy="10" stdDeviation="8" floodColor="#0a1422" floodOpacity="0.45" />
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* hero photo, source crop mapped onto the full card */}
|
|
<rect width={card.w} height={card.h} fill="#1a2433" />
|
|
{hero && crop.w > 0 && crop.h > 0 && (
|
|
<image
|
|
href={hero.url}
|
|
x={(-crop.x * card.w) / crop.w}
|
|
y={(-crop.y * card.h) / crop.h}
|
|
width={(hero.w * card.w) / crop.w}
|
|
height={(hero.h * card.h) / crop.h}
|
|
preserveAspectRatio="none"
|
|
/>
|
|
)}
|
|
{t.hero.scrim?.enabled && (
|
|
<rect width={card.w} height={card.h} fill={t.hero.scrim.color} opacity={t.hero.scrim.opacity} />
|
|
)}
|
|
|
|
{t.elements.map((e, idx) => {
|
|
const key = String(idx);
|
|
if (e.hidden) return null; // toggled off in the editor
|
|
if (e.type === 'insert') {
|
|
const ph = assets.photos[e.photo ?? ''];
|
|
if (!ph || !e.w) return null;
|
|
const h = (e.w * ph.h) / ph.w;
|
|
const b = e.border_px ?? 0;
|
|
return (
|
|
<g
|
|
key={key} ref={setGroupRef(key)}
|
|
transform={`translate(${e.x} ${e.y})${e.rotate ? ` rotate(${e.rotate} ${e.w / 2} ${h / 2})` : ''}`}
|
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
|
style={{ cursor: onMove ? 'move' : undefined }}
|
|
>
|
|
<rect x={-b} y={-b} width={e.w + 2 * b} height={h + 2 * b} fill="#ffffff"
|
|
filter={e.shadow ? 'url(#qsl-insert-shadow)' : undefined} />
|
|
<image href={ph.url} width={e.w} height={h} preserveAspectRatio="none" />
|
|
</g>
|
|
);
|
|
}
|
|
if (e.type === 'country') {
|
|
const size = e.size ?? 30;
|
|
const flagH = size * 1.4;
|
|
const flagW = flagH * (4 / 3);
|
|
const hasFlag = !!assets.flagUrl;
|
|
return (
|
|
<g
|
|
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
|
style={{ cursor: onMove ? 'move' : undefined }}
|
|
>
|
|
{hasFlag && <image href={assets.flagUrl} width={flagW} height={flagH} />}
|
|
<text
|
|
x={hasFlag ? flagW + 16 : 0} y={flagH / 2}
|
|
fontSize={size} fontWeight={700} fontFamily="'Segoe UI', Arial, sans-serif"
|
|
fill="#ffffff" stroke="#22364e" strokeWidth={4} paintOrder="stroke"
|
|
strokeLinejoin="round" dominantBaseline="middle"
|
|
>
|
|
{e.label === 'auto' ? '' : e.label}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|
|
// Text elements. Glossy/western calls render through the ported canvas
|
|
// FX as an <image> (its trimmed bounds already give a tight frame, so
|
|
// no CAP_INSET shim). Everything else is plain SVG text; the inner
|
|
// translate pulls the glyphs up to the em-box cap line so the element's
|
|
// y means "top of the visible letters".
|
|
const fx = fxImages.get(idx);
|
|
return (
|
|
<g
|
|
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
|
style={{ cursor: onMove ? 'move' : undefined }}
|
|
>
|
|
{fx ? (
|
|
<image href={fx.url} x={0} y={0} width={fx.w} height={fx.h} />
|
|
) : (
|
|
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
|
|
<TextStack e={e} idx={idx} content={e.text ?? ''} />
|
|
</g>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{t.qso_box?.enabled && (
|
|
<g
|
|
ref={setGroupRef('box')}
|
|
transform={`translate(${t.qso_box.x} ${t.qso_box.y})`}
|
|
onPointerDown={startDrag('box', t.qso_box.x, t.qso_box.y)}
|
|
style={{ cursor: onMove ? 'move' : undefined }}
|
|
>
|
|
<QSOBoxView box={t.qso_box} values={qso_fields} />
|
|
</g>
|
|
)}
|
|
|
|
{/* selection outline (editor only) */}
|
|
{selBox && onSelect && (
|
|
<g transform={selBox.t} pointerEvents="none">
|
|
<rect
|
|
x={selBox.x - 8} y={selBox.y - 8} width={selBox.w + 16} height={selBox.h + 16}
|
|
fill="none" stroke="#38bdf8" strokeWidth={3 * scale > 3 ? 3 * scale : 3}
|
|
strokeDasharray={`${10 * scale} ${6 * scale}`}
|
|
/>
|
|
</g>
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|