This commit is contained in:
2026-06-13 01:34:45 +02:00
parent 408b29896c
commit 3cb2e466d8
21 changed files with 1285 additions and 130 deletions
+68 -46
View File
@@ -59,6 +59,8 @@ type ProfileInfo struct {
Grid string
CQZone int
ITUZone int
Rig string
Antenna string
}
// 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,
}
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
if len(plan.inserts) == 0 {
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
// (top / bottom / natural-best), so the user gets genuinely distinct cards
// to choose from rather than three near-identical golds.
styles := []string{"gel_gold", "gel_silver", "classic_white_outline"}
if plan.cool {
styles[0], styles[1] = styles[1], styles[0] // lead with silver on cool photos
}
p1 := buildTemplateBiased(plan, primary, styles[0], flipTop)
p2 := buildTemplateBiased(plan, alternate, styles[1], flipBottom)
p3 := buildTemplateBiased(plan, alternate, styles[2], flipNatural)
// Three proposals that showcase genuinely different call looks (font +
// style + position), echoing the classic printed-QSL styles: a rounded
// glossy gold, a distressed slab "vintage", and an angular silver. The
// middle one is a clean full-bleed (no inserts) so the set always offers a
// minimal option.
p1 := buildTemplateBiased(plan, primary, "gel_gold", "Baloo 2", flipTop)
p2 := buildTemplateBiased(plan, ArchetypeFullBleed, "gel_gold_grunge", "Alfa Slab One", flipBottom)
p3 := buildTemplateBiased(plan, primary, "gel_silver", "Archivo Black", flipNatural)
out := []Template{p1, p2, p3}
for i := range out {
@@ -285,20 +284,6 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
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 {
inserts := 0
for _, e := range t.Elements {
@@ -312,20 +297,6 @@ func archetypeLabel(t Template) string {
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.
type pxRect struct{ x, y, w, h float64 }
@@ -336,7 +307,23 @@ const (
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
crop := cropForCard(hero)
@@ -371,13 +358,14 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
params := adaptStyle(style, zoneLuma, hero.Warmth)
call := plan.profile.Callsign
size := math.Min(zone.w*0.9/(0.72*float64(max(len(call), 3))), zone.h*0.95)
callW := 0.72 * size * float64(len(call))
cw := charWidthFactor(font)
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
callY := zone.y + (zone.h-size)*0.3
t.Elements = append(t.Elements, Element{
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)),
StylePreset: style, StyleParams: params,
})
@@ -417,6 +405,22 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
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}
if hasInserts {
t.Elements = append(t.Elements, insertEls...)
@@ -435,6 +439,24 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) 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
// toward the detail centroid so the interesting content stays visible.
func cropForCard(p PhotoAnalysis) Crop {