Qsl
This commit is contained in:
@@ -15,6 +15,10 @@ import { FONT_WEIGHTS } from './qslAssets';
|
||||
// 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;
|
||||
@@ -140,7 +144,8 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
|
||||
const shadow = p.shadow ?? DEF_SHADOW;
|
||||
const outline = p.outline_color ?? '#2a3f5c';
|
||||
const outlineW = ow || 9;
|
||||
const isGel = preset === 'gel_gold' || preset === 'gel_silver';
|
||||
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;
|
||||
@@ -179,6 +184,19 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
|
||||
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">
|
||||
@@ -205,6 +223,14 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
|
||||
</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>
|
||||
@@ -213,9 +239,23 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
|
||||
);
|
||||
}
|
||||
|
||||
// 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 colW = (box.w - 56) / Math.max(box.fields.length, 1);
|
||||
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} />
|
||||
@@ -223,13 +263,13 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{box.title}
|
||||
</text>
|
||||
{box.fields.map((f, i) => (
|
||||
<g key={f} transform={`translate(${28 + i * colW} ${box.h * 0.42})`}>
|
||||
{cols.map(({ f, x }) => (
|
||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||
</text>
|
||||
<text y={26} fontSize={30} fontWeight={700} fill="#14243a"
|
||||
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||
{values[f] ?? ''}
|
||||
</text>
|
||||
@@ -253,17 +293,39 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
|
||||
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);
|
||||
|
||||
// Measure the selected group for the dashed selection outline.
|
||||
// Measure the selected group for the dashed selection outline. For text we
|
||||
// use the glyphs' real ink metrics — getBBox would span the font's full
|
||||
// ascent/descent, leaving a tall dead band above the letters and a frame
|
||||
// that doesn't hug the call.
|
||||
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 b = g.getBBox();
|
||||
const transform = g.getAttribute('transform') ?? '';
|
||||
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
|
||||
if (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]);
|
||||
}, [selected, model, assets, width, t.elements]);
|
||||
|
||||
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
|
||||
(ev: React.PointerEvent<SVGGElement>) => {
|
||||
@@ -331,6 +393,7 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
|
||||
|
||||
{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;
|
||||
@@ -372,14 +435,19 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
|
||||
</g>
|
||||
);
|
||||
}
|
||||
// text elements
|
||||
// Text elements. The inner translate pulls the glyphs up by the em-box
|
||||
// top→cap-height gap so the element's y means "top of the visible
|
||||
// letters" — without it the call sits ~0.2em below its box and can't
|
||||
// be pushed to the card's top edge.
|
||||
return (
|
||||
<g
|
||||
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
||||
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||
style={{ cursor: onMove ? 'move' : undefined }}
|
||||
>
|
||||
<TextStack e={e} idx={idx} content={e.text ?? ''} />
|
||||
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
|
||||
<TextStack e={e} idx={idx} content={e.text ?? ''} />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user