Qsl
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user