Qsl
This commit is contained in:
@@ -1217,6 +1217,7 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
|||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
}
|
}
|
||||||
|
a.maybeAutoSendEQSL(q)
|
||||||
}
|
}
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
@@ -5391,6 +5392,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.extsvc.OnQSOLogged(id)
|
||||||
}
|
}
|
||||||
|
a.maybeAutoSendEQSL(q)
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
-9
@@ -31,6 +31,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
keyQSLEmailSubject = "qsl.email_subject"
|
keyQSLEmailSubject = "qsl.email_subject"
|
||||||
keyQSLEmailBody = "qsl.email_body"
|
keyQSLEmailBody = "qsl.email_body"
|
||||||
|
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -73,7 +74,7 @@ type QSLPresetInfo struct {
|
|||||||
// so picking through the native dialog is the reliable route to real paths).
|
// so picking through the native dialog is the reliable route to real paths).
|
||||||
func (a *App) QSLPickPhotos() ([]string, error) {
|
func (a *App) QSLPickPhotos() ([]string, error) {
|
||||||
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
Title: "Choose card photos (1–6)",
|
Title: "Choose card photos (1–3)",
|
||||||
Filters: []wruntime.FileFilter{
|
Filters: []wruntime.FileFilter{
|
||||||
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
|
||||||
},
|
},
|
||||||
@@ -86,8 +87,8 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
|||||||
if len(photoPaths) == 0 {
|
if len(photoPaths) == 0 {
|
||||||
return nil, fmt.Errorf("no photos selected")
|
return nil, fmt.Errorf("no photos selected")
|
||||||
}
|
}
|
||||||
if len(photoPaths) > 6 {
|
if len(photoPaths) > 3 {
|
||||||
return nil, fmt.Errorf("at most 6 photos (got %d)", len(photoPaths))
|
return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths))
|
||||||
}
|
}
|
||||||
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
|
||||||
for _, p := range photoPaths {
|
for _, p := range photoPaths {
|
||||||
@@ -433,10 +434,11 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLEmailTemplates is the subject/body pair of the eQSL e-mail.
|
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
|
||||||
type QSLEmailTemplates struct {
|
type QSLEmailTemplates struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
AutoSend bool `json:"auto_send"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
|
||||||
@@ -445,7 +447,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody)
|
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -455,10 +457,11 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
|
|||||||
if b := m[keyQSLEmailBody]; b != "" {
|
if b := m[keyQSLEmailBody]; b != "" {
|
||||||
out.Body = b
|
out.Body = b
|
||||||
}
|
}
|
||||||
|
out.AutoSend = m[keyQSLAutoSend] == "1"
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLSaveEmailTemplates persists the eQSL e-mail templates.
|
// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
|
||||||
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
@@ -466,7 +469,43 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
|
|||||||
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
|
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return a.settings.Set(a.ctx, keyQSLEmailBody, t.Body)
|
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := "0"
|
||||||
|
if t.AutoSend {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
|
||||||
|
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
|
||||||
|
// default template for the active profile, and the QSO not already confirmed.
|
||||||
|
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
|
||||||
|
// the frontend acts on — never sends from here. Silent when any condition fails.
|
||||||
|
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
|
||||||
|
if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
|
||||||
|
"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────
|
||||||
@@ -493,6 +532,8 @@ func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
|
|||||||
Callsign: p.Callsign,
|
Callsign: p.Callsign,
|
||||||
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
|
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
|
||||||
Grid: p.MyGrid,
|
Grid: p.MyGrid,
|
||||||
|
Rig: p.MyRig,
|
||||||
|
Antenna: p.MyAntenna,
|
||||||
}
|
}
|
||||||
if p.MyCQZone != nil {
|
if p.MyCQZone != nil {
|
||||||
info.CQZone = *p.MyCQZone
|
info.CQZone = *p.MyCQZone
|
||||||
@@ -510,9 +551,57 @@ func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Most users define rig/antenna in the operating manager (band defaults),
|
||||||
|
// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
|
||||||
|
// default so the designer knows the station line has data to show.
|
||||||
|
if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
|
||||||
|
for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
|
||||||
|
d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
|
||||||
|
if e != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Rig == "" {
|
||||||
|
info.Rig = d.StationName
|
||||||
|
}
|
||||||
|
if info.Antenna == "" {
|
||||||
|
info.Antenna = d.AntennaName
|
||||||
|
}
|
||||||
|
if info.Rig != "" && info.Antenna != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qslRigAntenna resolves the rig and antenna names for a QSO: the values
|
||||||
|
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
|
||||||
|
// default for the QSO's band (so the preview and back-entered QSOs aren't
|
||||||
|
// blank).
|
||||||
|
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
|
||||||
|
rig, ant = q.MyRig, q.MyAntenna
|
||||||
|
if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
|
||||||
|
return rig, ant
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return rig, ant
|
||||||
|
}
|
||||||
|
band := q.Band
|
||||||
|
if band == "" {
|
||||||
|
band = "20m"
|
||||||
|
}
|
||||||
|
if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
|
||||||
|
if rig == "" {
|
||||||
|
rig = d.StationName
|
||||||
|
}
|
||||||
|
if ant == "" {
|
||||||
|
ant = d.AntennaName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rig, ant
|
||||||
|
}
|
||||||
|
|
||||||
// qslVars builds the full placeholder map (profile + QSO) and the country
|
// qslVars builds the full placeholder map (profile + QSO) and the country
|
||||||
// block resolution for one render.
|
// block resolution for one render.
|
||||||
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
|
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
|
||||||
@@ -532,6 +621,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
|||||||
"profile.grid": info.Grid,
|
"profile.grid": info.Grid,
|
||||||
"profile.cq_zone": zone(info.CQZone),
|
"profile.cq_zone": zone(info.CQZone),
|
||||||
"profile.itu_zone": zone(info.ITUZone),
|
"profile.itu_zone": zone(info.ITUZone),
|
||||||
|
"profile.rig": info.Rig,
|
||||||
|
"profile.antenna": info.Antenna,
|
||||||
|
|
||||||
"qso.callsign": q.Callsign,
|
"qso.callsign": q.Callsign,
|
||||||
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
|
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
|
||||||
@@ -543,6 +634,7 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
|
|||||||
"qso.qsl_msg": q.QSLMsg,
|
"qso.qsl_msg": q.QSLMsg,
|
||||||
"qso.name": q.Name,
|
"qso.name": q.Name,
|
||||||
}
|
}
|
||||||
|
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
|
||||||
if q.FreqHz != nil {
|
if q.FreqHz != nil {
|
||||||
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
|
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-5
@@ -41,6 +41,7 @@ import { Menubar, type Menu } from '@/components/Menubar';
|
|||||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||||
|
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
@@ -631,6 +632,19 @@ export default function App() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
// Single band map docked beside the table (toggled by the toolbar button,
|
||||||
|
// visible across tabs). Independent of the multi-band "Band Map" tab.
|
||||||
|
const [showBandMap, setShowBandMap] = useState(false);
|
||||||
|
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
||||||
|
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
|
||||||
|
);
|
||||||
|
const toggleBandMapSide = useCallback(() => {
|
||||||
|
setBandMapSide((s) => {
|
||||||
|
const next = s === 'right' ? 'left' : 'right';
|
||||||
|
writeUiPref('bandmap.side', next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
||||||
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
|
||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
@@ -2256,10 +2270,10 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'bandmap' ? 'default' : 'outline'}
|
variant={showBandMap ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setActiveTab('bandmap')}
|
onClick={() => setShowBandMap((v) => !v)}
|
||||||
title="Open the Band Map tab (several bands side by side)"
|
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
Band map
|
Band map
|
||||||
@@ -2376,7 +2390,7 @@ export default function App() {
|
|||||||
className={cn('bg-card shadow-sm border-border',
|
className={cn('bg-card shadow-sm border-border',
|
||||||
compact
|
compact
|
||||||
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
|
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
|
||||||
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[760px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
|
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2552,7 +2566,8 @@ export default function App() {
|
|||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className="grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)] grid-cols-[1fr]">
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
|
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
|
||||||
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
|
||||||
<TabsList className="px-3 shrink-0">
|
<TabsList className="px-3 shrink-0">
|
||||||
@@ -3081,6 +3096,21 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{showBandMap && (
|
||||||
|
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
|
||||||
|
<BandMap
|
||||||
|
side={bandMapSide}
|
||||||
|
onToggleSide={toggleBandMapSide}
|
||||||
|
band={band}
|
||||||
|
spots={spots.filter((s) => s.band === band)}
|
||||||
|
spotStatus={spotStatus}
|
||||||
|
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
|
onSpotClick={handleSpotClick}
|
||||||
|
onClose={() => setShowBandMap(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
@@ -3173,6 +3203,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AutoEQSL
|
||||||
|
onSent={(call) => showToast(`eQSL sent to ${call}`)}
|
||||||
|
onError={(msg) => showToast(msg)}
|
||||||
|
/>
|
||||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||||
<SendEQSLModal
|
<SendEQSLModal
|
||||||
open={eqslQsoId !== null}
|
open={eqslQsoId !== null}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
GetSecretStatus, SetPassphrase, RemovePassphrase,
|
||||||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||||
|
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
@@ -462,6 +463,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
});
|
});
|
||||||
const [emailMsg, setEmailMsg] = useState('');
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
|
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||||
|
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||||
|
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false });
|
||||||
|
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
|
||||||
// ClubLog Country File (cty.xml) exception status.
|
// ClubLog Country File (cty.xml) exception status.
|
||||||
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
|
||||||
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
|
||||||
@@ -632,6 +637,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
|
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||||
reloadAudioDevices();
|
reloadAudioDevices();
|
||||||
reloadDvk();
|
reloadDvk();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -791,6 +797,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
|
await QSLSaveEmailTemplates(eqslCfg as any);
|
||||||
await SaveBackupSettings(backupCfg as any);
|
await SaveBackupSettings(backupCfg as any);
|
||||||
await SaveQSLDefaults(qslDefaults as any);
|
await SaveQSLDefaults(qslDefaults as any);
|
||||||
await SaveExternalServices(extSvc as any);
|
await SaveExternalServices(extSvc as any);
|
||||||
@@ -860,16 +867,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
||||||
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
|
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>Operator name</Label>
|
|
||||||
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
|
|
||||||
<div className="text-[10px] text-muted-foreground">Your first name — used as the signature on QSL cards.</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Owner callsign</Label>
|
<Label>Owner callsign</Label>
|
||||||
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
|
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
|
||||||
<div className="text-[10px] text-muted-foreground">Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
|
<div className="text-[10px] text-muted-foreground">Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Operator name</Label>
|
||||||
|
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
|
||||||
|
<div className="text-[10px] text-muted-foreground">Your first name — used as the signature on QSL cards.</div>
|
||||||
|
</div>
|
||||||
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
|
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
|
||||||
Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
|
Auto-filled from the callsign — editable (stamped as MY_* on each QSO)
|
||||||
</div>
|
</div>
|
||||||
@@ -3077,6 +3084,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 mt-2 border-t border-border space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">eQSL card e-mail</Label>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
|
||||||
|
</div>
|
||||||
|
<Input className="h-8" placeholder="Subject" value={eqslCfg.subject}
|
||||||
|
onChange={(e) => setEqslField({ subject: e.target.value })} />
|
||||||
|
<Textarea rows={3} className="text-sm" placeholder="Body" value={eqslCfg.body}
|
||||||
|
onChange={(e) => setEqslField({ body: e.target.value })} />
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
|
||||||
|
Auto-send eQSL when a QSO is logged
|
||||||
|
</label>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
|
||||||
|
// log (auto-send on, recipient e-mail known, default template, not already
|
||||||
|
// sent) and emits "qsl:autosend"; this component does the part Go can't —
|
||||||
|
// render the card off-screen, rasterize it to JPEG and hand it back to
|
||||||
|
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { EventsOn } from '../../../wailsjs/runtime/runtime';
|
||||||
|
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
|
||||||
|
import type { RenderModel } from './qslTypes';
|
||||||
|
import { loadCardAssets, type CardAssets } from './qslAssets';
|
||||||
|
import { CardPreview } from './CardPreview';
|
||||||
|
import { rasterizeCard } from './rasterize';
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
qsoId: number;
|
||||||
|
templateId: number;
|
||||||
|
callsign: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSent?: (callsign: string) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoEQSL({ onSent, onError }: Props) {
|
||||||
|
const queue = useRef<Job[]>([]);
|
||||||
|
const busy = useRef(false);
|
||||||
|
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
|
||||||
|
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
// Pull the next job, fetch its render model + assets, then mount it (the
|
||||||
|
// effect below rasterizes once the DOM has it).
|
||||||
|
const pump = useCallback(async () => {
|
||||||
|
if (busy.current) return;
|
||||||
|
const job = queue.current.shift();
|
||||||
|
if (!job) return;
|
||||||
|
busy.current = true;
|
||||||
|
try {
|
||||||
|
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
|
||||||
|
const assets = await loadCardAssets(model.template, job.templateId);
|
||||||
|
setCurrent({ job, model, assets });
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(`Auto eQSL: ${e}`);
|
||||||
|
busy.current = false;
|
||||||
|
void pump();
|
||||||
|
}
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
|
||||||
|
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
|
||||||
|
void pump();
|
||||||
|
});
|
||||||
|
return () => off();
|
||||||
|
}, [pump]);
|
||||||
|
|
||||||
|
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await document.fonts.ready;
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
||||||
|
if (cancelled || !svgEl.current) return;
|
||||||
|
const card = current.model.template.card;
|
||||||
|
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
|
||||||
|
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
|
||||||
|
onSent?.(current.job.callsign);
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(`Auto eQSL: ${e}`);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCurrent(null);
|
||||||
|
busy.current = false;
|
||||||
|
void pump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [current, pump, onSent, onError]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
// Off-screen at full card resolution so the rasterized output matches the
|
||||||
|
// editor preview exactly.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<CardPreview
|
||||||
|
model={current.model}
|
||||||
|
assets={current.assets}
|
||||||
|
width={current.model.template.card.w}
|
||||||
|
svgRef={(el) => { svgEl.current = el; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ import { FONT_WEIGHTS } from './qslAssets';
|
|||||||
// An editor selection: an element index or the QSO box.
|
// An editor selection: an element index or the QSO box.
|
||||||
export type CardSelection = number | 'box' | null;
|
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 {
|
interface Props {
|
||||||
model: RenderModel;
|
model: RenderModel;
|
||||||
assets: CardAssets;
|
assets: CardAssets;
|
||||||
@@ -140,7 +144,8 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
|
|||||||
const shadow = p.shadow ?? DEF_SHADOW;
|
const shadow = p.shadow ?? DEF_SHADOW;
|
||||||
const outline = p.outline_color ?? '#2a3f5c';
|
const outline = p.outline_color ?? '#2a3f5c';
|
||||||
const outlineW = ow || 9;
|
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 halo = p.halo ?? DEF_HALO;
|
||||||
const bevel = p.bevel_offset ?? DEF_BEVEL;
|
const bevel = p.bevel_offset ?? DEF_BEVEL;
|
||||||
const shine = p.shine ?? DEF_SHINE;
|
const shine = p.shine ?? DEF_SHINE;
|
||||||
@@ -179,6 +184,19 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
|
|||||||
opacity={shine.opacity}
|
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 that belong to this stack (unique ids per element index) */}
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
|
<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>
|
</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%">
|
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
<feGaussianBlur stdDeviation={shadow.blur} />
|
<feGaussianBlur stdDeviation={shadow.blur} />
|
||||||
</filter>
|
</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.
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
<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">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{box.title}
|
{box.title}
|
||||||
</text>
|
</text>
|
||||||
{box.fields.map((f, i) => (
|
{cols.map(({ f, x }) => (
|
||||||
<g key={f} transform={`translate(${28 + i * colW} ${box.h * 0.42})`}>
|
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||||
</text>
|
</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">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{values[f] ?? ''}
|
{values[f] ?? ''}
|
||||||
</text>
|
</text>
|
||||||
@@ -253,17 +293,39 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
|
|||||||
const groupRefs = useRef(new Map<string, SVGGElement>());
|
const groupRefs = useRef(new Map<string, SVGGElement>());
|
||||||
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (selected === null || selected === undefined) { setSelBox(null); return; }
|
if (selected === null || selected === undefined) { setSelBox(null); return; }
|
||||||
const key = String(selected);
|
const key = String(selected);
|
||||||
const g = groupRefs.current.get(key);
|
const g = groupRefs.current.get(key);
|
||||||
if (!g) { setSelBox(null); return; }
|
if (!g) { setSelBox(null); return; }
|
||||||
const b = g.getBBox();
|
|
||||||
const transform = g.getAttribute('transform') ?? '';
|
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 });
|
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) =>
|
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
|
||||||
(ev: React.PointerEvent<SVGGElement>) => {
|
(ev: React.PointerEvent<SVGGElement>) => {
|
||||||
@@ -331,6 +393,7 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
|
|||||||
|
|
||||||
{t.elements.map((e, idx) => {
|
{t.elements.map((e, idx) => {
|
||||||
const key = String(idx);
|
const key = String(idx);
|
||||||
|
if (e.hidden) return null; // toggled off in the editor
|
||||||
if (e.type === 'insert') {
|
if (e.type === 'insert') {
|
||||||
const ph = assets.photos[e.photo ?? ''];
|
const ph = assets.photos[e.photo ?? ''];
|
||||||
if (!ph || !e.w) return null;
|
if (!ph || !e.w) return null;
|
||||||
@@ -372,14 +435,19 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
|
|||||||
</g>
|
</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 (
|
return (
|
||||||
<g
|
<g
|
||||||
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
|
||||||
onPointerDown={startDrag(idx, e.x, e.y)}
|
onPointerDown={startDrag(idx, e.x, e.y)}
|
||||||
style={{ cursor: onMove ? 'move' : undefined }}
|
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>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
|
||||||
import type { CardSelection } from './CardPreview';
|
import type { CardSelection } from './CardPreview';
|
||||||
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
import { StylePresetPicker, NumberField } from './StylePresetPicker';
|
||||||
@@ -21,16 +22,29 @@ interface Props {
|
|||||||
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
|
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
|
||||||
onPatchBox: (patch: Partial<QSOBox>) => void;
|
onPatchBox: (patch: Partial<QSOBox>) => void;
|
||||||
onScrim: (enabled: boolean) => void;
|
onScrim: (enabled: boolean) => void;
|
||||||
|
onSelect?: (sel: CardSelection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
callsign: 'Callsign',
|
callsign: 'Callsign',
|
||||||
operator: 'Operator name',
|
operator: 'Operator name',
|
||||||
info_line: 'Info line',
|
info_line: 'Info line',
|
||||||
country: 'Country block',
|
country: 'Country + flag',
|
||||||
insert: 'Photo insert',
|
insert: 'Photo insert',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A readable layer name, disambiguating the two info lines and numbering inserts.
|
||||||
|
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
|
||||||
|
if (el.type === 'info_line') {
|
||||||
|
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
|
||||||
|
}
|
||||||
|
if (el.type === 'insert') {
|
||||||
|
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
|
||||||
|
return `Photo insert ${n}`;
|
||||||
|
}
|
||||||
|
return TYPE_LABELS[el.type] ?? el.type;
|
||||||
|
}
|
||||||
|
|
||||||
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
|
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
|
||||||
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
|
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
|
||||||
onPatch: (idx: number, patch: Partial<CardElement>) => void;
|
onPatch: (idx: number, patch: Partial<CardElement>) => void;
|
||||||
@@ -71,12 +85,12 @@ function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim }: Props) {
|
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
|
||||||
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
|
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
|
||||||
const box = template.qso_box;
|
const box = template.qso_box;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-72 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
|
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
|
||||||
<div className="space-y-2 rounded-md border border-border p-3">
|
<div className="space-y-2 rounded-md border border-border p-3">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
@@ -88,6 +102,27 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Layers: show/hide each piece, click a name to edit it. */}
|
||||||
|
<div className="space-y-1 rounded-md border border-border p-3">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
|
||||||
|
{template.elements.map((el, i) => (
|
||||||
|
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
|
||||||
|
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
|
||||||
|
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
|
||||||
|
{layerLabel(el, i, template.elements)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{box && (
|
||||||
|
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
|
||||||
|
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
|
||||||
|
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
|
||||||
|
QSO info box
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-md border border-border p-3">
|
<div className="space-y-2 rounded-md border border-border p-3">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
|
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
import {
|
||||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||||
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
|
||||||
} from '../../../wailsjs/go/main/App';
|
} from '../../../wailsjs/go/main/App';
|
||||||
import { main } from '../../../wailsjs/go/models';
|
|
||||||
import type {
|
import type {
|
||||||
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
|
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
|
||||||
} from './qslTypes';
|
} from './qslTypes';
|
||||||
@@ -66,9 +63,6 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||||
const [deleteArm, setDeleteArm] = useState(0);
|
const [deleteArm, setDeleteArm] = useState(0);
|
||||||
const [mailSubject, setMailSubject] = useState('');
|
|
||||||
const [mailBody, setMailBody] = useState('');
|
|
||||||
const [mailSaved, setMailSaved] = useState(false);
|
|
||||||
const svgEl = useRef<SVGSVGElement | null>(null);
|
const svgEl = useRef<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,7 +74,6 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
setEditing(null);
|
setEditing(null);
|
||||||
void refreshSaved();
|
void refreshSaved();
|
||||||
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
|
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
|
||||||
void QSLGetEmailTemplates().then((t) => { setMailSubject(t.subject); setMailBody(t.body); setMailSaved(false); });
|
|
||||||
void loadFonts().then(({ fonts }) =>
|
void loadFonts().then(({ fonts }) =>
|
||||||
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
|
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -103,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
async function choosePhotos() {
|
async function choosePhotos() {
|
||||||
try {
|
try {
|
||||||
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
const paths = ((await QSLPickPhotos()) ?? []) as string[];
|
||||||
if (paths.length) setPhotoPaths(paths.slice(0, 6));
|
if (paths.length) setPhotoPaths(paths.slice(0, 3));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -237,7 +230,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="max-w-[1180px]">
|
<DialogContent className="max-w-[1260px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="size-5 text-amber-500" />
|
<Sparkles className="size-5 text-amber-500" />
|
||||||
@@ -261,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">New design</h3>
|
<h3 className="text-sm font-semibold">New design</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Pick 1–6 photos — OpsLog analyzes them and proposes three card designs
|
Pick 1–3 photos — OpsLog analyzes them and proposes three card designs
|
||||||
with your callsign, name, zones and country placed automatically.
|
with your callsign, name, zones and country placed automatically.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -317,25 +310,9 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<p className="text-xs text-muted-foreground">
|
||||||
<h3 className="text-sm font-semibold">eQSL e-mail message</h3>
|
The eQSL e-mail message and the auto-send option are in Settings → E-mail (SMTP).
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
{'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'} fill in per QSO.
|
|
||||||
</p>
|
|
||||||
<Input className="h-8" value={mailSubject} placeholder="Subject"
|
|
||||||
onChange={(e) => { setMailSubject(e.target.value); setMailSaved(false); }} />
|
|
||||||
<Textarea rows={3} value={mailBody} placeholder="Body"
|
|
||||||
onChange={(e) => { setMailBody(e.target.value); setMailSaved(false); }} />
|
|
||||||
<Button variant="outline" size="sm" disabled={mailSaved}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await QSLSaveEmailTemplates(new main.QSLEmailTemplates({ subject: mailSubject, body: mailBody }));
|
|
||||||
setMailSaved(true);
|
|
||||||
} catch (e) { setError(String(e)); }
|
|
||||||
}}>
|
|
||||||
{mailSaved ? 'Saved' : 'Save message'}
|
|
||||||
</Button>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -392,6 +369,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
onPatchElement={patchElement}
|
onPatchElement={patchElement}
|
||||||
onPatchBox={patchBox}
|
onPatchBox={patchBox}
|
||||||
onScrim={onScrim}
|
onScrim={onScrim}
|
||||||
|
onSelect={setSel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ function SliderRow({ label, value, min, max, step, onChange }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="w-24 shrink-0 text-xs text-muted-foreground">{label}</Label>
|
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
|
||||||
<input type="range" className="flex-1" min={min} max={max} step={step}
|
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
|
||||||
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
|
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
|
||||||
<span className="w-8 text-right text-xs tabular-nums">{value}</span>
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface CardElement {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
rotate?: number;
|
rotate?: number;
|
||||||
|
hidden?: boolean; // toggled off in the editor
|
||||||
// text elements
|
// text elements
|
||||||
text?: string;
|
text?: string;
|
||||||
font?: string;
|
font?: string;
|
||||||
|
|||||||
@@ -0,0 +1,576 @@
|
|||||||
|
// Canvas-2D call-text renderer, ported faithfully from the user's two
|
||||||
|
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
|
||||||
|
// SVG filters can only approximate these looks; the exact technique is
|
||||||
|
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
|
||||||
|
// boolean compositing, a candy gloss that hugs the outline, an orange inner
|
||||||
|
// shadow, and organic multi-layer grunge.
|
||||||
|
//
|
||||||
|
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
|
||||||
|
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
|
||||||
|
// embeds that as an <image> so the same output drives the editor preview,
|
||||||
|
// thumbnails and the final e-mail rasterization.
|
||||||
|
|
||||||
|
export type TextFxKind = 'glossy' | 'western';
|
||||||
|
|
||||||
|
export interface TextFxParams {
|
||||||
|
kind: TextFxKind;
|
||||||
|
text: string;
|
||||||
|
weight: string; // canvas font weight, e.g. "800" or "400"
|
||||||
|
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
|
||||||
|
size: number; // font size in card px
|
||||||
|
space: number; // letter spacing in px
|
||||||
|
cTop: string;
|
||||||
|
cMid: string;
|
||||||
|
cBot: string;
|
||||||
|
cDark: string; // dark edge / outline
|
||||||
|
cOuter?: string; // silver rim (glossy only)
|
||||||
|
// glossy
|
||||||
|
plump?: number; // 0..0.06 — inflation as a fraction of size
|
||||||
|
edge?: number; // 0..1 — dark edge weight
|
||||||
|
outerw?: number; // 0..1 — silver rim weight
|
||||||
|
gloss?: number; // 0..1 — gloss intensity
|
||||||
|
glossH?: number; // 0..1 — gloss height
|
||||||
|
glossI?: number; // 0..0.14 — gloss inset
|
||||||
|
innerB?: number; // 0..1 — bottom inner shadow
|
||||||
|
// western
|
||||||
|
depth?: number; // 3D extrusion depth in px
|
||||||
|
angle?: number; // extrusion direction in radians
|
||||||
|
slant?: number; // shear, -0.4..0.4
|
||||||
|
grunge?: number; // 0..1 — distress amount
|
||||||
|
bevel?: number; // 0..1 — top-edge light
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFxResult {
|
||||||
|
url: string; // PNG data URL of the trimmed text
|
||||||
|
w: number; // logical (card-px) width
|
||||||
|
h: number; // logical (card-px) height
|
||||||
|
}
|
||||||
|
|
||||||
|
const SS = 2; // supersample for crisp downscaling
|
||||||
|
|
||||||
|
function supportsFilter(c: CanvasRenderingContext2D) {
|
||||||
|
return typeof c.filter === 'string';
|
||||||
|
}
|
||||||
|
function shade(hex: string, pct: number) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
|
||||||
|
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
|
||||||
|
}
|
||||||
|
function deepen(hex: string) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
|
||||||
|
}
|
||||||
|
function rustOf(hex: string) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.min(255, Math.round((n >> 16) * 0.72));
|
||||||
|
const g = Math.round(((n >> 8) & 255) * 0.49);
|
||||||
|
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
|
||||||
|
return 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||||
|
}
|
||||||
|
function mulberry32(seed: number) {
|
||||||
|
return function () {
|
||||||
|
seed |= 0;
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function hashStr(s: string) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
|
||||||
|
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
|
||||||
|
const probe = document.createElement('canvas').getContext('2d')!;
|
||||||
|
const font = p.weight + ' ' + p.size + 'px ' + p.family;
|
||||||
|
probe.letterSpacing = p.space + 'px';
|
||||||
|
probe.font = font;
|
||||||
|
const m = probe.measureText(p.text);
|
||||||
|
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
|
||||||
|
const capH = asc + desc;
|
||||||
|
const plumpPx = p.size * p.plump;
|
||||||
|
const edgeW = p.size * 0.1 * p.edge;
|
||||||
|
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
|
||||||
|
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
|
||||||
|
const w = Math.ceil(m.width + pad * 2);
|
||||||
|
const h = Math.ceil(capH + pad * 2);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w * scale;
|
||||||
|
canvas.height = h * scale;
|
||||||
|
const c = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
const x = pad, y = pad + asc;
|
||||||
|
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
|
||||||
|
|
||||||
|
const mk = () => {
|
||||||
|
const k = document.createElement('canvas');
|
||||||
|
k.width = canvas.width;
|
||||||
|
k.height = canvas.height;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const setT = (cx: CanvasRenderingContext2D) => {
|
||||||
|
cx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
cx.letterSpacing = p.space + 'px';
|
||||||
|
cx.font = font;
|
||||||
|
cx.textBaseline = 'alphabetic';
|
||||||
|
cx.lineJoin = 'round';
|
||||||
|
cx.lineCap = 'round';
|
||||||
|
};
|
||||||
|
const glyph = (delta: number, dx?: number, dy?: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
setT(g);
|
||||||
|
g.fillStyle = '#fff';
|
||||||
|
g.strokeStyle = '#fff';
|
||||||
|
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
|
||||||
|
if (delta > 0.1) {
|
||||||
|
g.lineWidth = delta * 2;
|
||||||
|
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
|
||||||
|
} else if (delta < -0.1) {
|
||||||
|
g.globalCompositeOperation = 'destination-out';
|
||||||
|
g.lineWidth = -delta * 2;
|
||||||
|
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-in';
|
||||||
|
g.drawImage(mask, 0, 0);
|
||||||
|
g.restore();
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
|
||||||
|
const k = glyph(plumpPx, 0, 0);
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-out';
|
||||||
|
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
|
||||||
|
g.restore();
|
||||||
|
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
|
||||||
|
};
|
||||||
|
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-in';
|
||||||
|
const gr = g.createLinearGradient(0, top, 0, bot);
|
||||||
|
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
|
||||||
|
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
|
||||||
|
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
|
||||||
|
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
|
||||||
|
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
|
||||||
|
g.fillStyle = gr;
|
||||||
|
g.fillRect(0, 0, k.width / scale, k.height / scale);
|
||||||
|
g.restore();
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const blurred = (src: HTMLCanvasElement, px: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
if (supportsFilter(g) && px > 0.3) {
|
||||||
|
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
g.filter = 'none';
|
||||||
|
} else {
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'source-in';
|
||||||
|
g.fillStyle = color;
|
||||||
|
g.fillRect(0, 0, k.width, k.height);
|
||||||
|
g.restore();
|
||||||
|
if (alpha < 1) {
|
||||||
|
const k2 = mk();
|
||||||
|
const g2 = k2.getContext('2d')!;
|
||||||
|
g2.globalAlpha = alpha;
|
||||||
|
g2.drawImage(k, 0, 0);
|
||||||
|
return k2;
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
|
||||||
|
cx.fillStyle = paint;
|
||||||
|
cx.fillText(p.text, x, y);
|
||||||
|
if (plumpPx > 0.1) {
|
||||||
|
cx.strokeStyle = paint;
|
||||||
|
cx.lineWidth = plumpPx * 2;
|
||||||
|
cx.strokeText(p.text, x, y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
setT(c);
|
||||||
|
|
||||||
|
// drop shadow
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = 'rgba(10,15,25,0.40)';
|
||||||
|
c.shadowBlur = p.size * 0.045 * scale;
|
||||||
|
c.shadowOffsetY = p.size * 0.03 * scale;
|
||||||
|
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
|
||||||
|
c.strokeStyle = 'rgba(10,15,25,0.40)';
|
||||||
|
c.strokeText(p.text, x, y);
|
||||||
|
c.restore();
|
||||||
|
|
||||||
|
const face = mk();
|
||||||
|
const f = face.getContext('2d')!;
|
||||||
|
setT(f);
|
||||||
|
|
||||||
|
if (p.outerw > 0) {
|
||||||
|
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
|
||||||
|
gOuter.addColorStop(0, '#ffffff');
|
||||||
|
gOuter.addColorStop(0.45, p.cOuter);
|
||||||
|
gOuter.addColorStop(1, shade(p.cOuter, -22));
|
||||||
|
f.lineWidth = outerW;
|
||||||
|
f.strokeStyle = gOuter;
|
||||||
|
f.strokeText(p.text, x, y);
|
||||||
|
}
|
||||||
|
if (p.edge > 0) {
|
||||||
|
f.lineWidth = edgeW + 2 * plumpPx;
|
||||||
|
f.strokeStyle = p.cDark;
|
||||||
|
f.strokeText(p.text, x, y);
|
||||||
|
}
|
||||||
|
const gBody = f.createLinearGradient(0, top, 0, bot);
|
||||||
|
gBody.addColorStop(0, p.cTop);
|
||||||
|
gBody.addColorStop(0.55, p.cMid);
|
||||||
|
gBody.addColorStop(1, p.cBot);
|
||||||
|
paintText(f, gBody);
|
||||||
|
|
||||||
|
if (p.innerB > 0) {
|
||||||
|
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
|
||||||
|
band = blurred(band, p.size * 0.04);
|
||||||
|
band = clipTo(band, glyph(plumpPx, 0, 0));
|
||||||
|
band = vRamp(band, 0.45, 0.8, false);
|
||||||
|
band = tint(band, deepen(p.cBot), 0.7);
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
f.globalAlpha = Math.min(1, p.innerB);
|
||||||
|
f.drawImage(band, 0, 0);
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.gloss > 0) {
|
||||||
|
const shiftY = p.glossH * capH;
|
||||||
|
const shiftX = 0.12 * shiftY;
|
||||||
|
const inset = p.size * p.glossI;
|
||||||
|
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
|
||||||
|
let band = contourBand(shiftX, shiftY, inset);
|
||||||
|
band = vRamp(band, clip0, clip1, true);
|
||||||
|
let halo = blurred(band, p.size * 0.014);
|
||||||
|
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
|
||||||
|
let core = mk();
|
||||||
|
core.getContext('2d')!.drawImage(band, 0, 0);
|
||||||
|
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
|
||||||
|
core = blurred(core, p.size * 0.007);
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
|
||||||
|
f.drawImage(halo, 0, 0);
|
||||||
|
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
|
||||||
|
f.drawImage(core, 0, 0);
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.drawImage(face, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
|
||||||
|
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
|
||||||
|
const sw = Math.max(2, Math.round(W / featurePx));
|
||||||
|
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
|
||||||
|
const small = document.createElement('canvas');
|
||||||
|
small.width = sw;
|
||||||
|
small.height = sh;
|
||||||
|
const sg = small.getContext('2d')!;
|
||||||
|
const id = sg.createImageData(sw, sh);
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
for (let i = 0; i < sw * sh; i++) {
|
||||||
|
const v = (rnd() * 255) | 0;
|
||||||
|
id.data[i * 4] = v;
|
||||||
|
id.data[i * 4 + 1] = v;
|
||||||
|
id.data[i * 4 + 2] = v;
|
||||||
|
id.data[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
sg.putImageData(id, 0, 0);
|
||||||
|
const big = document.createElement('canvas');
|
||||||
|
big.width = W;
|
||||||
|
big.height = H;
|
||||||
|
const bg = big.getContext('2d')!;
|
||||||
|
bg.imageSmoothingEnabled = true;
|
||||||
|
bg.imageSmoothingQuality = 'high';
|
||||||
|
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
|
||||||
|
bg.drawImage(small, 0, 0, W, H);
|
||||||
|
if (supportsFilter(bg)) bg.filter = 'none';
|
||||||
|
const src = bg.getImageData(0, 0, W, H);
|
||||||
|
const out = bg.createImageData(W, H);
|
||||||
|
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
|
||||||
|
for (let i = 0; i < W * H; i++) {
|
||||||
|
const v = src.data[i * 4] / 255;
|
||||||
|
let a = (v - thresh) * k;
|
||||||
|
a = a < 0 ? 0 : a > 1 ? 1 : a;
|
||||||
|
out.data[i * 4] = 255;
|
||||||
|
out.data[i * 4 + 1] = 255;
|
||||||
|
out.data[i * 4 + 2] = 255;
|
||||||
|
out.data[i * 4 + 3] = (a * 255) | 0;
|
||||||
|
}
|
||||||
|
const mask = document.createElement('canvas');
|
||||||
|
mask.width = W;
|
||||||
|
mask.height = H;
|
||||||
|
const mg = mask.getContext('2d')!;
|
||||||
|
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
|
||||||
|
const tmp = document.createElement('canvas');
|
||||||
|
tmp.width = W;
|
||||||
|
tmp.height = H;
|
||||||
|
tmp.getContext('2d')!.putImageData(out, 0, 0);
|
||||||
|
mg.drawImage(tmp, 0, 0);
|
||||||
|
if (supportsFilter(mg)) mg.filter = 'none';
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
|
||||||
|
const probe = document.createElement('canvas').getContext('2d')!;
|
||||||
|
const font = p.weight + ' ' + p.size + 'px ' + p.family;
|
||||||
|
probe.letterSpacing = p.space + 'px';
|
||||||
|
probe.font = font;
|
||||||
|
const m = probe.measureText(p.text);
|
||||||
|
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
|
||||||
|
const capH = asc + desc;
|
||||||
|
const edgeW = p.size * 0.07;
|
||||||
|
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
|
||||||
|
const slantPad = Math.abs(p.slant) * capH;
|
||||||
|
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
|
||||||
|
const w = Math.ceil(m.width + pad * 2);
|
||||||
|
const h = Math.ceil(capH + pad * 2);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w * scale;
|
||||||
|
canvas.height = h * scale;
|
||||||
|
const c = canvas.getContext('2d')!;
|
||||||
|
|
||||||
|
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
|
||||||
|
const top = y - asc, bot = y + desc;
|
||||||
|
|
||||||
|
const mk = () => {
|
||||||
|
const k = document.createElement('canvas');
|
||||||
|
k.width = canvas.width;
|
||||||
|
k.height = canvas.height;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const setT = (cx: CanvasRenderingContext2D) => {
|
||||||
|
cx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
|
||||||
|
cx.letterSpacing = p.space + 'px';
|
||||||
|
cx.font = font;
|
||||||
|
cx.textBaseline = 'alphabetic';
|
||||||
|
cx.lineJoin = 'round';
|
||||||
|
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
|
||||||
|
};
|
||||||
|
const glyph = (dxx?: number, dyy?: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
setT(g);
|
||||||
|
g.fillStyle = '#fff';
|
||||||
|
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-in';
|
||||||
|
g.drawImage(mask, 0, 0);
|
||||||
|
g.restore();
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const blurred = (src: HTMLCanvasElement, px: number) => {
|
||||||
|
const k = mk();
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
if (supportsFilter(g) && px > 0.3) {
|
||||||
|
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
g.filter = 'none';
|
||||||
|
} else {
|
||||||
|
g.drawImage(src, 0, 0);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
|
||||||
|
const g = k.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'source-in';
|
||||||
|
g.fillStyle = color;
|
||||||
|
g.fillRect(0, 0, k.width, k.height);
|
||||||
|
g.restore();
|
||||||
|
const k2 = mk();
|
||||||
|
const g2 = k2.getContext('2d')!;
|
||||||
|
g2.globalAlpha = alpha;
|
||||||
|
g2.drawImage(k, 0, 0);
|
||||||
|
return k2;
|
||||||
|
};
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
setT(c);
|
||||||
|
|
||||||
|
// drop shadow
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = 'rgba(15,8,4,0.50)';
|
||||||
|
c.shadowBlur = p.size * 0.035 * scale;
|
||||||
|
c.shadowOffsetX = p.size * 0.02 * scale;
|
||||||
|
c.shadowOffsetY = p.size * 0.035 * scale;
|
||||||
|
c.lineWidth = edgeW;
|
||||||
|
c.strokeStyle = 'rgba(15,8,4,0.50)';
|
||||||
|
c.fillStyle = 'rgba(15,8,4,0.50)';
|
||||||
|
c.strokeText(p.text, x, y);
|
||||||
|
c.fillText(p.text, x, y);
|
||||||
|
c.restore();
|
||||||
|
|
||||||
|
// 3D extrusion
|
||||||
|
if (p.depth > 0) {
|
||||||
|
const deepCol = shade(p.cDark, -4);
|
||||||
|
c.lineWidth = edgeW;
|
||||||
|
const steps = Math.ceil(p.depth);
|
||||||
|
for (let i = steps; i >= 1; i--) {
|
||||||
|
const t = i / steps;
|
||||||
|
c.strokeStyle = deepCol;
|
||||||
|
c.fillStyle = deepCol;
|
||||||
|
c.strokeText(p.text, x + dx * t, y + dy * t);
|
||||||
|
c.fillText(p.text, x + dx * t, y + dy * t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const face = mk();
|
||||||
|
const f = face.getContext('2d')!;
|
||||||
|
setT(f);
|
||||||
|
|
||||||
|
f.lineWidth = edgeW;
|
||||||
|
f.strokeStyle = p.cDark;
|
||||||
|
f.strokeText(p.text, x, y);
|
||||||
|
|
||||||
|
const gBody = f.createLinearGradient(0, top, 0, bot);
|
||||||
|
gBody.addColorStop(0, p.cTop);
|
||||||
|
gBody.addColorStop(0.5, p.cMid);
|
||||||
|
gBody.addColorStop(1, p.cBot);
|
||||||
|
f.fillStyle = gBody;
|
||||||
|
f.fillText(p.text, x, y);
|
||||||
|
|
||||||
|
const M = glyph(0, 0);
|
||||||
|
|
||||||
|
if (p.bevel > 0) {
|
||||||
|
let band = glyph(0, 0);
|
||||||
|
const g = band.getContext('2d')!;
|
||||||
|
g.save();
|
||||||
|
g.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
g.globalCompositeOperation = 'destination-out';
|
||||||
|
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
|
||||||
|
g.restore();
|
||||||
|
band = blurred(band, p.size * 0.008);
|
||||||
|
band = clipTo(band, M);
|
||||||
|
band = tintAlpha(band, '#ffeba8', p.bevel);
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
f.drawImage(band, 0, 0);
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.grunge > 0) {
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
const S = p.size * scale;
|
||||||
|
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
|
||||||
|
const rust = rustOf(p.cBot);
|
||||||
|
const layers = [
|
||||||
|
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
|
||||||
|
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
|
||||||
|
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
|
||||||
|
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
|
||||||
|
];
|
||||||
|
f.save();
|
||||||
|
f.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
f.globalCompositeOperation = 'source-atop';
|
||||||
|
for (const L of layers) {
|
||||||
|
let k = clipTo(L.mask, M);
|
||||||
|
k = tintAlpha(k, L.color, L.a * p.grunge);
|
||||||
|
f.drawImage(k, 0, 0);
|
||||||
|
}
|
||||||
|
f.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
c.drawImage(face, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim transparent padding so the element's x/y maps to the visible glyphs.
|
||||||
|
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
const data = ctx.getImageData(0, 0, W, H).data;
|
||||||
|
let minX = W, minY = H, maxX = -1, maxY = -1;
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (data[(y * W + x) * 4 + 3] > 8) {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxX < minX) return { canvas, w: W, h: H };
|
||||||
|
const cw = maxX - minX + 1, ch = maxY - minY + 1;
|
||||||
|
const out = document.createElement('canvas');
|
||||||
|
out.width = cw;
|
||||||
|
out.height = ch;
|
||||||
|
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
|
||||||
|
return { canvas: out, w: cw, h: ch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTextFx(p: TextFxParams): TextFxResult {
|
||||||
|
let raw: HTMLCanvasElement;
|
||||||
|
if (p.kind === 'western') {
|
||||||
|
raw = renderWestern(
|
||||||
|
{
|
||||||
|
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
|
||||||
|
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
|
||||||
|
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
|
||||||
|
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
|
||||||
|
},
|
||||||
|
SS,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
raw = renderGlossy(
|
||||||
|
{
|
||||||
|
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
|
||||||
|
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
|
||||||
|
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
|
||||||
|
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
|
||||||
|
},
|
||||||
|
SS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const t = trim(raw);
|
||||||
|
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
|
||||||
|
}
|
||||||
@@ -1129,6 +1129,7 @@ export namespace main {
|
|||||||
export class QSLEmailTemplates {
|
export class QSLEmailTemplates {
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
auto_send: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSLEmailTemplates(source);
|
return new QSLEmailTemplates(source);
|
||||||
@@ -1138,6 +1139,7 @@ export namespace main {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.subject = source["subject"];
|
this.subject = source["subject"];
|
||||||
this.body = source["body"];
|
this.body = source["body"];
|
||||||
|
this.auto_send = source["auto_send"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSLFontInfo {
|
export class QSLFontInfo {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2016 The Alfa Slab One Project Authors (http://www.jmsole.cl | info@jmsole.cl), with Reserved Font Name "Alfa Slab".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright (c) 2011 by Sorkin Type Co (www.sorkintype.com),
|
||||||
|
with Reserved Font Name "Rye".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
@@ -28,6 +28,8 @@ var fontFiles = []struct {
|
|||||||
{"Lilita One", "display", "LilitaOne-Regular.ttf", false},
|
{"Lilita One", "display", "LilitaOne-Regular.ttf", false},
|
||||||
{"Baloo 2", "display", "Baloo2-Variable.ttf", true},
|
{"Baloo 2", "display", "Baloo2-Variable.ttf", true},
|
||||||
{"Oswald", "display", "Oswald-Variable.ttf", true},
|
{"Oswald", "display", "Oswald-Variable.ttf", true},
|
||||||
|
{"Alfa Slab One", "display", "AlfaSlabOne-Regular.ttf", false}, // heavy slab serif (HS0ZLE-style)
|
||||||
|
{"Rye", "display", "Rye-Regular.ttf", false}, // Western slab
|
||||||
{"Great Vibes", "script", "GreatVibes-Regular.ttf", false},
|
{"Great Vibes", "script", "GreatVibes-Regular.ttf", false},
|
||||||
{"Allura", "script", "Allura-Regular.ttf", false},
|
{"Allura", "script", "Allura-Regular.ttf", false},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type ProfileInfo struct {
|
|||||||
Grid string
|
Grid string
|
||||||
CQZone int
|
CQZone int
|
||||||
ITUZone int
|
ITUZone int
|
||||||
|
Rig string
|
||||||
|
Antenna string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutEngine proposes card templates from analyzed photos. The heuristic
|
// LayoutEngine proposes card templates from analyzed photos. The heuristic
|
||||||
@@ -253,27 +255,24 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
|
|||||||
cool: sorted[0].Warmth < 0.02,
|
cool: sorted[0].Warmth < 0.02,
|
||||||
}
|
}
|
||||||
if len(sorted) > 1 {
|
if len(sorted) > 1 {
|
||||||
plan.inserts = sorted[1:min(len(sorted), 5)] // up to 4 (bottom strip), side column uses 3
|
plan.inserts = sorted[1:min(len(sorted), 3)] // hero + up to 2 inserts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only side-column inserts (or none): a bottom strip collides with the QSO
|
||||||
|
// box, which is what produced the overlapping mess.
|
||||||
primary := ArchetypeSideColumn
|
primary := ArchetypeSideColumn
|
||||||
if len(plan.inserts) == 0 {
|
if len(plan.inserts) == 0 {
|
||||||
primary = ArchetypeFullBleed
|
primary = ArchetypeFullBleed
|
||||||
} else if len(plan.inserts) >= 4 && bandQuiet(plan.hero, gridRows-5, gridRows) {
|
|
||||||
primary = ArchetypeBottomStrip
|
|
||||||
}
|
}
|
||||||
alternate := alternateArchetype(primary, len(plan.inserts))
|
|
||||||
|
|
||||||
// Three proposals that vary BOTH the call style and its vertical position
|
// Three proposals that showcase genuinely different call looks (font +
|
||||||
// (top / bottom / natural-best), so the user gets genuinely distinct cards
|
// style + position), echoing the classic printed-QSL styles: a rounded
|
||||||
// to choose from rather than three near-identical golds.
|
// glossy gold, a distressed slab "vintage", and an angular silver. The
|
||||||
styles := []string{"gel_gold", "gel_silver", "classic_white_outline"}
|
// middle one is a clean full-bleed (no inserts) so the set always offers a
|
||||||
if plan.cool {
|
// minimal option.
|
||||||
styles[0], styles[1] = styles[1], styles[0] // lead with silver on cool photos
|
p1 := buildTemplateBiased(plan, primary, "gel_gold", "Baloo 2", flipTop)
|
||||||
}
|
p2 := buildTemplateBiased(plan, ArchetypeFullBleed, "gel_gold_grunge", "Alfa Slab One", flipBottom)
|
||||||
p1 := buildTemplateBiased(plan, primary, styles[0], flipTop)
|
p3 := buildTemplateBiased(plan, primary, "gel_silver", "Archivo Black", flipNatural)
|
||||||
p2 := buildTemplateBiased(plan, alternate, styles[1], flipBottom)
|
|
||||||
p3 := buildTemplateBiased(plan, alternate, styles[2], flipNatural)
|
|
||||||
|
|
||||||
out := []Template{p1, p2, p3}
|
out := []Template{p1, p2, p3}
|
||||||
for i := range out {
|
for i := range out {
|
||||||
@@ -285,20 +284,6 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func alternateArchetype(primary string, inserts int) string {
|
|
||||||
switch primary {
|
|
||||||
case ArchetypeSideColumn:
|
|
||||||
if inserts >= 2 {
|
|
||||||
return ArchetypeBottomStrip
|
|
||||||
}
|
|
||||||
return ArchetypeFullBleed
|
|
||||||
case ArchetypeBottomStrip:
|
|
||||||
return ArchetypeSideColumn
|
|
||||||
default:
|
|
||||||
return ArchetypeFullBleed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func archetypeLabel(t Template) string {
|
func archetypeLabel(t Template) string {
|
||||||
inserts := 0
|
inserts := 0
|
||||||
for _, e := range t.Elements {
|
for _, e := range t.Elements {
|
||||||
@@ -312,20 +297,6 @@ func archetypeLabel(t Template) string {
|
|||||||
return "with inserts"
|
return "with inserts"
|
||||||
}
|
}
|
||||||
|
|
||||||
// bandQuiet reports whether the mean detail of grid rows [r0,r1) is low
|
|
||||||
// enough to host inserts or text.
|
|
||||||
func bandQuiet(p PhotoAnalysis, r0, r1 int) bool {
|
|
||||||
var sum float64
|
|
||||||
n := 0
|
|
||||||
for r := r0; r < r1; r++ {
|
|
||||||
for c := 0; c < gridCols; c++ {
|
|
||||||
sum += p.Cells[r][c].Detail
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n > 0 && sum/float64(n) < 0.25
|
|
||||||
}
|
|
||||||
|
|
||||||
// pxRect is a rectangle in card pixel space.
|
// pxRect is a rectangle in card pixel space.
|
||||||
type pxRect struct{ x, y, w, h float64 }
|
type pxRect struct{ x, y, w, h float64 }
|
||||||
|
|
||||||
@@ -336,7 +307,23 @@ const (
|
|||||||
flipBottom // restrict the callsign to the bottom half
|
flipBottom // restrict the callsign to the bottom half
|
||||||
)
|
)
|
||||||
|
|
||||||
func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) Template {
|
// charWidthFactor is a rough per-font average glyph width (in ems) for the
|
||||||
|
// heavy display faces, used to size the callsign so it fills its zone without
|
||||||
|
// overflowing. The frontend can refine with real measureText later.
|
||||||
|
func charWidthFactor(font string) float64 {
|
||||||
|
switch font {
|
||||||
|
case "Alfa Slab One", "Rye":
|
||||||
|
return 0.82
|
||||||
|
case "Baloo 2":
|
||||||
|
return 0.72
|
||||||
|
case "Oswald":
|
||||||
|
return 0.55
|
||||||
|
default: // Archivo Black, Lilita One
|
||||||
|
return 0.72
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTemplateBiased(plan proposalPlan, archetype, style, font string, flip int) Template {
|
||||||
hero := plan.hero
|
hero := plan.hero
|
||||||
crop := cropForCard(hero)
|
crop := cropForCard(hero)
|
||||||
|
|
||||||
@@ -371,13 +358,14 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
|
|||||||
params := adaptStyle(style, zoneLuma, hero.Warmth)
|
params := adaptStyle(style, zoneLuma, hero.Warmth)
|
||||||
|
|
||||||
call := plan.profile.Callsign
|
call := plan.profile.Callsign
|
||||||
size := math.Min(zone.w*0.9/(0.72*float64(max(len(call), 3))), zone.h*0.95)
|
cw := charWidthFactor(font)
|
||||||
callW := 0.72 * size * float64(len(call))
|
size := math.Min(zone.w*0.9/(cw*float64(max(len(call), 3))), zone.h*0.95)
|
||||||
|
callW := cw * size * float64(len(call))
|
||||||
callX := zone.x + (zone.w-callW)/2
|
callX := zone.x + (zone.w-callW)/2
|
||||||
callY := zone.y + (zone.h-size)*0.3
|
callY := zone.y + (zone.h-size)*0.3
|
||||||
t.Elements = append(t.Elements, Element{
|
t.Elements = append(t.Elements, Element{
|
||||||
Type: ElemCallsign, Text: "{profile.callsign}",
|
Type: ElemCallsign, Text: "{profile.callsign}",
|
||||||
Font: FontDisplayDefault, Size: math.Round(size),
|
Font: font, Size: math.Round(size),
|
||||||
X: math.Round(clamp(callX, 40, cardW-80)), Y: math.Round(clamp(callY, 30, cardH-size-30)),
|
X: math.Round(clamp(callX, 40, cardW-80)), Y: math.Round(clamp(callY, 30, cardH-size-30)),
|
||||||
StylePreset: style, StyleParams: params,
|
StylePreset: style, StyleParams: params,
|
||||||
})
|
})
|
||||||
@@ -417,6 +405,22 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
|
|||||||
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
|
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Operating conditions (rig / antenna), stacked next to the zones line.
|
||||||
|
// Always added so the "Show on card" toggle exists, but hidden by default
|
||||||
|
// when the profile has no rig/antenna yet (avoids an empty "Rig · Ant").
|
||||||
|
txt, stationHidden := stationLineText(plan.profile)
|
||||||
|
stationY := infoY + 40
|
||||||
|
if infoY < callY { // zones line sits above the call → stack station above it
|
||||||
|
stationY = infoY - 40
|
||||||
|
}
|
||||||
|
t.Elements = append(t.Elements, Element{
|
||||||
|
Type: ElemInfoLine, Text: txt, Hidden: stationHidden,
|
||||||
|
Font: FontInfoLine, Size: 26,
|
||||||
|
X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(stationY, 30, cardH-60)),
|
||||||
|
StylePreset: "outlined_white",
|
||||||
|
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
|
||||||
|
})
|
||||||
|
|
||||||
occupied := []pxRect{zone}
|
occupied := []pxRect{zone}
|
||||||
if hasInserts {
|
if hasInserts {
|
||||||
t.Elements = append(t.Elements, insertEls...)
|
t.Elements = append(t.Elements, insertEls...)
|
||||||
@@ -435,6 +439,24 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stationLineText builds the rig/antenna line for the profile and whether it
|
||||||
|
// should start hidden. When the profile has neither, it returns the full
|
||||||
|
// template text with hidden=true so the editor still offers the toggle (it
|
||||||
|
// resolves cleanly once the user fills My rig / My antenna). Values fill in
|
||||||
|
// from {profile.*} at render time.
|
||||||
|
func stationLineText(p ProfileInfo) (text string, hidden bool) {
|
||||||
|
switch {
|
||||||
|
case p.Rig != "" && p.Antenna != "":
|
||||||
|
return "Rig {qso.my_rig} · Ant {qso.my_antenna}", false
|
||||||
|
case p.Rig != "":
|
||||||
|
return "Rig {qso.my_rig}", false
|
||||||
|
case p.Antenna != "":
|
||||||
|
return "Ant {qso.my_antenna}", false
|
||||||
|
default:
|
||||||
|
return "Rig {qso.my_rig} · Ant {qso.my_antenna}", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cropForCard crops the photo to the card aspect ratio, sliding the window
|
// cropForCard crops the photo to the card aspect ratio, sliding the window
|
||||||
// toward the detail centroid so the interesting content stays visible.
|
// toward the detail centroid so the interesting content stays visible.
|
||||||
func cropForCard(p PhotoAnalysis) Crop {
|
func cropForCard(p PhotoAnalysis) Crop {
|
||||||
|
|||||||
@@ -164,19 +164,23 @@ func TestProposeThreeDistinctWithInserts(t *testing.T) {
|
|||||||
t.Errorf("proposal %d invalid: %v", i, err)
|
t.Errorf("proposal %d invalid: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The three proposals must use three distinct call styles from the palette
|
// The three proposals must showcase distinct call looks: each style is a
|
||||||
// (the user asked for variety in both style and position).
|
// known preset, and no two proposals share the same font+style pairing.
|
||||||
allowed := map[string]bool{"gel_gold": true, "gel_silver": true, "classic_white_outline": true}
|
seenStyle := map[string]bool{}
|
||||||
seen := map[string]bool{}
|
seenFont := map[string]bool{}
|
||||||
for i, tmpl := range out {
|
for i, tmpl := range out {
|
||||||
s := findElement(t, tmpl, ElemCallsign).StylePreset
|
call := findElement(t, tmpl, ElemCallsign)
|
||||||
if !allowed[s] {
|
if _, ok := Presets[call.StylePreset]; !ok {
|
||||||
t.Errorf("proposal %d: unexpected call style %q", i, s)
|
t.Errorf("proposal %d: unknown call style %q", i, call.StylePreset)
|
||||||
}
|
}
|
||||||
if seen[s] {
|
if seenStyle[call.StylePreset] {
|
||||||
t.Errorf("proposal %d reuses call style %q", i, s)
|
t.Errorf("proposal %d reuses call style %q", i, call.StylePreset)
|
||||||
}
|
}
|
||||||
seen[s] = true
|
if seenFont[call.Font] {
|
||||||
|
t.Errorf("proposal %d reuses call font %q", i, call.Font)
|
||||||
|
}
|
||||||
|
seenStyle[call.StylePreset] = true
|
||||||
|
seenFont[call.Font] = true
|
||||||
}
|
}
|
||||||
// At least one proposal uses the insert photos.
|
// At least one proposal uses the insert photos.
|
||||||
if countInserts(out[0]) == 0 && countInserts(out[1]) == 0 && countInserts(out[2]) == 0 {
|
if countInserts(out[0]) == 0 && countInserts(out[1]) == 0 && countInserts(out[2]) == 0 {
|
||||||
|
|||||||
+36
-10
@@ -23,12 +23,12 @@ var Presets = map[string]Preset{
|
|||||||
Label: "Gel gold",
|
Label: "Gel gold",
|
||||||
AllowedParams: gelParams,
|
AllowedParams: gelParams,
|
||||||
Defaults: StyleParams{
|
Defaults: StyleParams{
|
||||||
Gradient: []string{"#FFD83A", "#FFC312", "#EE9400"},
|
Gradient: []string{"#FFE15A", "#FFC312", "#E07A00"},
|
||||||
Shine: &Shine{Coverage: 0.5, Opacity: 0.95},
|
Shine: &Shine{Coverage: 0.52, Opacity: 0.95},
|
||||||
OutlineColor: "#2a3f5c", OutlineWidth: 9,
|
OutlineColor: "#2a3f5c", OutlineWidth: 10,
|
||||||
Halo: &Halo{Color: "#cdd9e4", Blur: 6, Opacity: 0.4},
|
Halo: &Halo{Color: "#cdd9e4", Blur: 6, Opacity: 0.4},
|
||||||
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 5, Color: "#14243a", Opacity: 0.5},
|
Shadow: &ShadowFx{Dx: 6, Dy: 9, Blur: 5, Color: "#14243a", Opacity: 0.55},
|
||||||
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#C27500", Light: "#FFEFA0"},
|
BevelOffset: &Bevel{Dx: -3, Dy: -6, Dark: "#A85F00", Light: "#FFF6C8"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"gel_silver": {
|
"gel_silver": {
|
||||||
@@ -36,12 +36,38 @@ var Presets = map[string]Preset{
|
|||||||
Label: "Gel silver",
|
Label: "Gel silver",
|
||||||
AllowedParams: gelParams,
|
AllowedParams: gelParams,
|
||||||
Defaults: StyleParams{
|
Defaults: StyleParams{
|
||||||
Gradient: []string{"#F4F7FA", "#C9D4DE", "#93A3B3"},
|
Gradient: []string{"#FBFDFF", "#C9D4DE", "#8496A8"},
|
||||||
Shine: &Shine{Coverage: 0.5, Opacity: 0.95},
|
Shine: &Shine{Coverage: 0.52, Opacity: 0.95},
|
||||||
OutlineColor: "#3c4654", OutlineWidth: 9,
|
OutlineColor: "#3c4654", OutlineWidth: 10,
|
||||||
Halo: &Halo{Color: "#dfe7ee", Blur: 6, Opacity: 0.4},
|
Halo: &Halo{Color: "#dfe7ee", Blur: 6, Opacity: 0.4},
|
||||||
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 5, Color: "#1b2530", Opacity: 0.5},
|
Shadow: &ShadowFx{Dx: 6, Dy: 9, Blur: 5, Color: "#1b2530", Opacity: 0.55},
|
||||||
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#76879a", Light: "#FFFFFF"},
|
BevelOffset: &Bevel{Dx: -3, Dy: -6, Dark: "#67788b", Light: "#FFFFFF"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"gel_gold_grunge": {
|
||||||
|
Name: "gel_gold_grunge",
|
||||||
|
Label: "Gel gold (vintage)",
|
||||||
|
AllowedParams: gelParams,
|
||||||
|
Defaults: StyleParams{
|
||||||
|
Gradient: []string{"#FFC83A", "#F0A21E", "#D87A00"},
|
||||||
|
Shine: &Shine{Coverage: 0.42, Opacity: 0.8},
|
||||||
|
OutlineColor: "#1c130a", OutlineWidth: 11,
|
||||||
|
Halo: &Halo{Color: "#d8c08a", Blur: 5, Opacity: 0.3},
|
||||||
|
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 4, Color: "#1a0f04", Opacity: 0.55},
|
||||||
|
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#A85F00", Light: "#FFE08A"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"gel_silver_grunge": {
|
||||||
|
Name: "gel_silver_grunge",
|
||||||
|
Label: "Gel silver (vintage)",
|
||||||
|
AllowedParams: gelParams,
|
||||||
|
Defaults: StyleParams{
|
||||||
|
Gradient: []string{"#EEF2F6", "#C2CDD8", "#8593A3"},
|
||||||
|
Shine: &Shine{Coverage: 0.42, Opacity: 0.8},
|
||||||
|
OutlineColor: "#16191f", OutlineWidth: 11,
|
||||||
|
Halo: &Halo{Color: "#cdd6df", Blur: 5, Opacity: 0.3},
|
||||||
|
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 4, Color: "#10141a", Opacity: 0.55},
|
||||||
|
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#6c7d8f", Light: "#FFFFFF"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"classic_white_outline": {
|
"classic_white_outline": {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ type Element struct {
|
|||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
Rotate float64 `json:"rotate,omitempty"`
|
Rotate float64 `json:"rotate,omitempty"`
|
||||||
|
Hidden bool `json:"hidden,omitempty"` // toggled off in the editor; kept in the template
|
||||||
|
|
||||||
// Text elements (callsign / operator / info_line).
|
// Text elements (callsign / operator / info_line).
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user