Files
2026-06-13 19:14:24 +02:00

813 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qslcard
import (
"fmt"
"image"
_ "image/jpeg" // hero/insert photo decoding
_ "image/png"
"math"
"os"
"sort"
"strings"
)
// Analysis grid resolution (spec section 5.1): 32 columns × 20 rows.
const (
gridCols = 32
gridRows = 20
)
// Default card geometry: 140×90 mm at 300 dpi.
const (
cardW = 1654
cardH = 1063
cardDPI = 300
cardBleed = 35
)
// detail thresholds: a cell is "quiet" (usable for text) below quietDetail;
// quietRelaxed is the fallback when a photo has no large calm zone.
const (
quietDetail = 0.2
quietRelaxed = 0.3
)
// Cell is one analysis-grid cell of a photo.
type Cell struct {
Detail float64 // mean gradient magnitude, 01
Luma float64 // mean relative luminance, 01
}
// PhotoAnalysis is the downscaled feature summary of one photo. All scores
// are deterministic so "Generate designs" always yields the same proposals.
type PhotoAnalysis struct {
Path string
W, H int // original pixel size
Cells [gridRows][gridCols]Cell
Quietness float64 // share of cells with detail < 0.15
AspectFit float64 // closeness to the 14:9 card ratio, 01
AvgLuma float64
Warmth float64 // mean (RB), positive = warm (sunset), negative = cool
Score float64 // hero suitability
}
// ProfileInfo is the slice of the active profile the engine needs (actual
// values are used only for sizing — templates store {profile.*} placeholders).
type ProfileInfo struct {
Callsign string
Operator string
Grid string
CQZone int
ITUZone int
Rig string
Antenna string
}
// LayoutEngine proposes card templates from analyzed photos. The heuristic
// engine is the default; a future AI engine implements the same contract and
// falls back to the heuristic one on any error.
type LayoutEngine interface {
Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]Template, error)
}
// AnalyzePhoto decodes and analyzes one photo file.
func AnalyzePhoto(path string) (PhotoAnalysis, error) {
f, err := os.Open(path)
if err != nil {
return PhotoAnalysis{}, fmt.Errorf("open photo: %w", err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return PhotoAnalysis{}, fmt.Errorf("decode photo %s: %w", path, err)
}
return AnalyzeImage(img, path), nil
}
// AnalyzeImage computes the analysis grid of an already-decoded image.
// Exported separately so tests can feed synthetic images.
func AnalyzeImage(img image.Image, path string) PhotoAnalysis {
b := img.Bounds()
origW, origH := b.Dx(), b.Dy()
// Downscale to ≤256 px on the long edge (nearest neighbor).
scale := 1.0
if long := max(origW, origH); long > 256 {
scale = 256.0 / float64(long)
}
w := max(1, int(float64(origW)*scale))
h := max(1, int(float64(origH)*scale))
luma := make([]float64, w*h)
var sumWarm float64
for y := 0; y < h; y++ {
sy := b.Min.Y + y*origH/h
for x := 0; x < w; x++ {
sx := b.Min.X + x*origW/w
r, g, bl, _ := img.At(sx, sy).RGBA()
rf, gf, bf := float64(r)/65535, float64(g)/65535, float64(bl)/65535
luma[y*w+x] = 0.2126*rf + 0.7152*gf + 0.0722*bf
sumWarm += rf - bf
}
}
p := PhotoAnalysis{Path: path, W: origW, H: origH, Warmth: sumWarm / float64(w*h)}
var lumaSum float64
quietCells := 0
for r := 0; r < gridRows; r++ {
y0, y1 := r*h/gridRows, (r+1)*h/gridRows
for c := 0; c < gridCols; c++ {
x0, x1 := c*w/gridCols, (c+1)*w/gridCols
var dSum, lSum float64
n, dn := 0, 0
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
l := luma[y*w+x]
lSum += l
n++
if x+1 < w {
dSum += math.Abs(luma[y*w+x+1] - l)
dn++
}
if y+1 < h {
dSum += math.Abs(luma[(y+1)*w+x] - l)
dn++
}
}
}
cell := Cell{}
if n > 0 {
cell.Luma = lSum / float64(n)
}
if dn > 0 {
// Mean neighbor difference ≈ 0.12 in textured regions; ×8
// stretches that to ~1 so the 01 thresholds are meaningful.
cell.Detail = math.Min(1, dSum/float64(dn)*8)
}
p.Cells[r][c] = cell
lumaSum += cell.Luma
if cell.Detail < 0.15 {
quietCells++
}
}
}
p.AvgLuma = lumaSum / (gridRows * gridCols)
p.Quietness = float64(quietCells) / (gridRows * gridCols)
const targetAR = 14.0 / 9.0
ar := float64(origW) / float64(origH)
p.AspectFit = math.Min(ar, targetAR) / math.Max(ar, targetAR)
resNorm := math.Min(1, float64(origW*origH)/(1600.0*1029.0))
p.Score = resNorm * p.AspectFit * (0.2 + 0.8*p.Quietness)
return p
}
// ── maximal quiet rectangle ─────────────────────────────────────────────
// cellRect is a rectangle in analysis-grid cell coordinates (inclusive
// row/col start, exclusive end).
type cellRect struct{ r0, c0, r1, c1 int }
func (r cellRect) rows() int { return r.r1 - r.r0 }
func (r cellRect) cols() int { return r.c1 - r.c0 }
// maximalQuietRect finds the best axis-aligned rectangle of cells where
// quiet() is true, at least minCols × minRows, preferring the top or bottom
// third (text near the card edges keeps the photo subject visible).
// O(rows²·cols) — trivial at 20×32.
func maximalQuietRect(quiet func(r, c int) bool, minCols, minRows int) (cellRect, bool) {
best := cellRect{}
bestScore := 0.0
for r0 := 0; r0 < gridRows; r0++ {
for r1 := r0 + 1; r1 <= gridRows; r1++ {
if r1-r0 < minRows {
continue
}
run := 0
for c := 0; c <= gridCols; c++ {
ok := c < gridCols
if ok {
for r := r0; r < r1; r++ {
if !quiet(r, c) {
ok = false
break
}
}
}
if ok {
run++
continue
}
if run >= minCols {
cand := cellRect{r0: r0, c0: c - run, r1: r1, c1: c}
score := float64(cand.rows()*cand.cols()) * positionBonus(cand)
if score > bestScore {
best, bestScore = cand, score
}
}
run = 0
}
}
}
return best, bestScore > 0
}
// positionBonus prefers rectangles whose center sits in the top or bottom
// third of the grid.
func positionBonus(r cellRect) float64 {
center := float64(r.r0+r.r1) / 2
if center < gridRows/3.0 || center > gridRows*2/3.0 {
return 1.25
}
return 1.0
}
// ── heuristic engine ────────────────────────────────────────────────────
// HeuristicEngine is the default, fully-offline layout engine.
type HeuristicEngine struct{}
// proposalPlan captures the decisions shared by the three proposals.
type proposalPlan struct {
hero PhotoAnalysis
inserts []PhotoAnalysis
profile ProfileInfo
cool bool // cool-toned photo → silver alternative
}
// Propose builds three distinct full templates (spec section 5.3):
// best archetype + gel gold, an alternate archetype, and the best archetype
// with the alternate color treatment.
func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]Template, error) {
if len(photos) == 0 {
return nil, fmt.Errorf("no photos to lay out")
}
if profile.Callsign == "" {
return nil, fmt.Errorf("active profile has no callsign — set one before generating designs")
}
sorted := make([]PhotoAnalysis, len(photos))
copy(sorted, photos)
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score })
plan := proposalPlan{
hero: sorted[0],
profile: profile,
cool: sorted[0].Warmth < 0.02,
}
if len(sorted) > 1 {
plan.inserts = sorted[1:min(len(sorted), 5)] // hero + up to 4 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
}
// 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 {
out[i].Name = fmt.Sprintf("%s — %s %d", profile.Callsign, archetypeLabel(out[i]), i+1)
if err := Validate(out[i], nil); err != nil {
return nil, fmt.Errorf("proposal %d invalid: %w", i+1, err)
}
}
return out, nil
}
func archetypeLabel(t Template) string {
inserts := 0
for _, e := range t.Elements {
if e.Type == ElemInsert {
inserts++
}
}
if inserts == 0 {
return "full bleed"
}
return "with inserts"
}
// pxRect is a rectangle in card pixel space.
type pxRect struct{ x, y, w, h float64 }
// Vertical bias for the callsign zone, used to spread the three proposals.
const (
flipNatural = iota // largest quiet rect, wherever it is
flipTop // restrict the callsign to the top half
flipBottom // restrict the callsign to the bottom half
)
// 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)
t := Template{
Schema: SchemaVersion,
Card: Card{W: cardW, H: cardH, DPI: cardDPI, BleedPx: cardBleed},
Hero: Hero{Photo: hero.Path, Crop: crop},
}
// Place inserts first so the callsign zone can steer clear of them — the
// call (and its trailing operator script) must never sit under an insert.
var insertEls []Element
var insertArea pxRect
hasInserts := archetype != ArchetypeFullBleed && len(plan.inserts) > 0
if hasInserts {
insertEls, insertArea = placeInserts(plan.inserts, hero, archetype)
}
exclude := insertArea
if exclude.w > 0 {
exclude = pxRect{x: exclude.x - 24, y: exclude.y - 24, w: exclude.w + 48, h: exclude.h + 48}
}
zone, found := callsignZone(hero, crop, archetype, flip, exclude)
if !found {
// Busy photo everywhere: darken it and drop the call on a band clear
// of the inserts.
t.Hero.Scrim = &Scrim{Enabled: true, Color: "#0b1a2e", Opacity: 0.25}
zone = fallbackZone(insertArea, flip)
}
zoneLuma := regionLuma(hero, crop, zone)
params := adaptStyle(style, zoneLuma, hero.Warmth)
call := plan.profile.Callsign
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: 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,
})
// Operator first name in script. Skipped when the profile's operator field
// is just the callsign — rendering the call a second time in cursive is
// redundant (the script slot is meant for the operator's name). Placed to
// trail past the call's tail, or below it when an insert would clip it.
if op := plan.profile.Operator; op != "" && !strings.EqualFold(op, call) {
opSize := math.Round(size * 0.42)
opX := callX + callW - size*0.12
opY := callY + size*0.6
if insertArea.w > 0 && opX+float64(len(op))*opSize*0.42 > insertArea.x {
opX = callX + size*0.12 // tuck the signature under the call instead
opY = callY + size*1.05
}
t.Elements = append(t.Elements, Element{
Type: ElemOperator, Text: "{profile.operator_name}",
Font: FontScriptDefault, Size: opSize, Rotate: -7,
X: math.Round(clamp(opX, 40, cardW-220)),
Y: math.Round(clamp(opY, 30, cardH-opSize-30)),
StylePreset: "script_white",
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 2},
})
}
infoY := callY + size*1.45
if infoY > cardH-120 {
infoY = callY - 60 // callsign near the bottom: info line goes above
}
t.Elements = append(t.Elements, Element{
Type: ElemInfoLine,
Text: "CQ Zone {profile.cq_zone} · ITU Zone {profile.itu_zone} · Loc. {profile.grid}",
Font: FontInfoLine, Size: 29,
X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(infoY, 30, cardH-70)),
StylePreset: "outlined_white",
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...)
occupied = append(occupied, insertArea)
}
box := placeQSOBox(plan.profile, zone, occupied)
t.QSOBox = &box
occupied = append(occupied, pxRect{x: box.X, y: box.Y, w: box.W, h: box.H})
cx, cy := quietestCorner(hero, crop, occupied)
t.Elements = append(t.Elements, Element{
Type: ElemCountry, Flag: "auto", Label: "auto", Size: 30,
X: math.Round(cx), Y: math.Round(cy),
})
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 {
targetAR := float64(cardW) / float64(cardH)
w, h := float64(p.W), float64(p.H)
cx, cy := detailCentroid(p)
if w/h > targetAR { // too wide: crop horizontally around the centroid
cw := h * targetAR
x := clamp(cx*w-cw/2, 0, w-cw)
return Crop{X: int(x), Y: 0, W: int(cw), H: int(h)}
}
ch := w / targetAR
y := clamp(cy*h-ch/2, 0, h-ch)
return Crop{X: 0, Y: int(y), W: int(w), H: int(ch)}
}
// detailCentroid returns the detail-weighted centroid in normalized [0,1]
// photo coordinates.
func detailCentroid(p PhotoAnalysis) (float64, float64) {
var sx, sy, sw float64
for r := 0; r < gridRows; r++ {
for c := 0; c < gridCols; c++ {
d := p.Cells[r][c].Detail
sx += d * (float64(c) + 0.5) / gridCols
sy += d * (float64(r) + 0.5) / gridRows
sw += d
}
}
if sw == 0 {
return 0.5, 0.5
}
return sx / sw, sy / sw
}
// cellInsideCrop reports whether grid cell (r,c) of the photo lies fully
// inside the crop window.
func cellInsideCrop(p PhotoAnalysis, crop Crop, r, c int) bool {
x0, x1 := c*p.W/gridCols, (c+1)*p.W/gridCols
y0, y1 := r*p.H/gridRows, (r+1)*p.H/gridRows
return x0 >= crop.X && x1 <= crop.X+crop.W && y0 >= crop.Y && y1 <= crop.Y+crop.H
}
// cellToCard converts a cell rectangle (photo grid space) to card pixels.
func cellToCard(p PhotoAnalysis, crop Crop, r cellRect) pxRect {
x0 := float64(r.c0*p.W/gridCols-crop.X) * cardW / float64(crop.W)
x1 := float64(r.c1*p.W/gridCols-crop.X) * cardW / float64(crop.W)
y0 := float64(r.r0*p.H/gridRows-crop.Y) * cardH / float64(crop.H)
y1 := float64(r.r1*p.H/gridRows-crop.Y) * cardH / float64(crop.H)
return pxRect{
x: clamp(x0, 0, cardW), y: clamp(y0, 0, cardH),
w: clamp(x1, 0, cardW) - clamp(x0, 0, cardW),
h: clamp(y1, 0, cardH) - clamp(y0, 0, cardH),
}
}
// callsignZone finds the calm zone for the callsign: ≥55% card width,
// ≥22% card height, preferring the top/bottom third. flip restricts the
// search to a vertical half (so the three proposals land in distinct
// positions), and exclude (card-space, w>0 to apply) keeps the zone off the
// insert column.
func callsignZone(p PhotoAnalysis, crop Crop, archetype string, flip int, exclude pxRect) (pxRect, bool) {
maxRow := gridRows
if archetype == ArchetypeBottomStrip {
maxRow = gridRows * 2 / 3 // the strip owns the bottom band
}
// Minimum size in cells, derived from the crop→card scale.
minCols := int(math.Ceil(0.55 * gridCols))
minRows := int(math.Ceil(0.22 * gridRows))
// flip is a preference, not a mandate: try the requested half first, then
// fall back to the natural best zone. Position variety must never push the
// callsign onto a busy region just to differ from another proposal.
tries := []int{flip}
if flip != flipNatural {
tries = append(tries, flipNatural)
}
for _, fl := range tries {
for _, threshold := range []float64{quietDetail, quietRelaxed} {
quiet := func(r, c int) bool {
if r >= maxRow || !cellInsideCrop(p, crop, r, c) {
return false
}
if fl == flipBottom && r < gridRows/2 {
return false
}
if fl == flipTop && r >= gridRows/2 {
return false
}
if exclude.w > 0 && cellCenterIn(p, crop, r, c, exclude) {
return false
}
return p.Cells[r][c].Detail < threshold
}
if rect, ok := maximalQuietRect(quiet, minCols, minRows); ok {
return cellToCard(p, crop, rect), true
}
}
}
return pxRect{}, false
}
// fallbackZone picks a callsign band when the photo has no quiet zone, kept
// clear of the inserts: opposite the strip for a wide bottom/top strip, beside
// a side column, honouring the flip preference for full-bleed cards.
func fallbackZone(insertArea pxRect, flip int) pxRect {
z := pxRect{x: cardW * 0.05, y: cardH * 0.06, w: cardW * 0.9, h: cardH * 0.26}
switch {
case insertArea.w == 0: // no inserts
if flip == flipBottom {
z.y = cardH * 0.62
}
case insertArea.w > cardW*0.6: // wide strip → band on the opposite side
if insertArea.y < cardH/2 {
z.y = cardH * 0.62
}
case insertArea.x > cardW/2: // right column
z.w = insertArea.x - 40 - z.x
if flip == flipBottom {
z.y = cardH * 0.62
}
default: // left column
z.x = insertArea.x + insertArea.w + 40
z.w = cardW - 40 - z.x
if flip == flipBottom {
z.y = cardH * 0.62
}
}
if z.w < cardW*0.35 { // guard against a degenerate sliver
z.x, z.w = cardW*0.05, cardW*0.9
}
return z
}
// cellCenterIn reports whether the card-space center of grid cell (r,c) falls
// inside rect.
func cellCenterIn(p PhotoAnalysis, crop Crop, r, c int, rect pxRect) bool {
cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
cx, cy := cell.x+cell.w/2, cell.y+cell.h/2
return cx >= rect.x && cx <= rect.x+rect.w && cy >= rect.y && cy <= rect.y+rect.h
}
// regionLuma averages the luma of the photo cells under a card-space rect.
func regionLuma(p PhotoAnalysis, crop Crop, zone pxRect) float64 {
var sum float64
n := 0
for r := 0; r < gridRows; r++ {
for c := 0; c < gridCols; c++ {
cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
if cell.w <= 0 || cell.h <= 0 {
continue
}
if cell.x+cell.w/2 >= zone.x && cell.x+cell.w/2 <= zone.x+zone.w &&
cell.y+cell.h/2 >= zone.y && cell.y+cell.h/2 <= zone.y+zone.h {
sum += p.Cells[r][c].Luma
n++
}
}
}
if n == 0 {
return p.AvgLuma
}
return sum / float64(n)
}
// adaptStyle tunes the preset defaults to the photo: bright placement zones
// get the dark outline + subdued halo, dark zones a stronger light halo and
// lighter outline; warm photos deepen the gold gradient.
func adaptStyle(preset string, zoneLuma, warmth float64) *StyleParams {
def := Presets[preset].Defaults
p := def // copy
if preset == "gel_gold" && warmth > 0.08 {
p.Gradient = []string{"#FFD83A", "#FFB000", "#E07E00"} // sunset ambers
}
if def.Halo != nil {
halo := *def.Halo
if zoneLuma > 0.55 {
p.OutlineColor = "#2a3f5c"
halo.Opacity = 0.25
} else {
p.OutlineColor = "#1d2e44"
halo.Opacity = 0.6
halo.Blur = 8
}
p.Halo = &halo
}
return &p
}
// placeInserts lays the remaining photos out as inserts and returns them
// plus the card area they occupy.
func placeInserts(inserts []PhotoAnalysis, hero PhotoAnalysis, archetype string) ([]Element, pxRect) {
const border = 14
if archetype == ArchetypeBottomStrip {
n := min(len(inserts), 4)
gap, margin := 24.0, 64.0
w := math.Min(420, (cardW-2*margin-float64(n-1)*gap)/float64(n))
var els []Element
x := margin
for i := 0; i < n; i++ {
ph := inserts[i]
h := w * float64(ph.H) / float64(ph.W)
rot := 1.5
if i%2 == 0 {
rot = -1.5
}
els = append(els, Element{
Type: ElemInsert, Photo: ph.Path,
X: math.Round(x), Y: math.Round(cardH - h - margin),
W: math.Round(w), BorderPx: border, Rotate: rot, Shadow: true,
})
x += w + gap
}
return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin}
}
// Side column: pick the calmer half of the hero photo. The insert width
// shrinks as needed so up to 4 framed photos stack down the side.
n := min(len(inserts), 4)
gap, margin := 20.0, 70.0
availH := float64(cardH) - 2*margin - float64(n-1)*gap
var arSum float64
for i := 0; i < n; i++ {
arSum += float64(inserts[i].H) / float64(inserts[i].W)
}
w := 400.0
if arSum > 0 {
w = clamp(availH/arSum, 240, 400) // fit all n vertically, within sane bounds
}
left := halfDetail(hero, true) < halfDetail(hero, false)
x := cardW - w - margin
if left {
x = margin
}
var els []Element
y := margin
for i := 0; i < n; i++ {
ph := inserts[i]
h := w * float64(ph.H) / float64(ph.W)
if y+h > cardH-margin {
break
}
rot := 1.2
if i%2 == 0 {
rot = -1.5
}
els = append(els, Element{
Type: ElemInsert, Photo: ph.Path,
X: math.Round(x), Y: math.Round(y),
W: w, BorderPx: border, Rotate: rot, Shadow: true,
})
y += h + gap
}
return els, pxRect{x: x - 10, y: 0, w: w + 20, h: float64(cardH)}
}
// halfDetail sums cell detail over the left or right half of the photo.
func halfDetail(p PhotoAnalysis, left bool) float64 {
var sum float64
c0, c1 := gridCols/2, gridCols
if left {
c0, c1 = 0, gridCols/2
}
for r := 0; r < gridRows; r++ {
for c := c0; c < c1; c++ {
sum += p.Cells[r][c].Detail
}
}
return sum
}
// placeQSOBox puts the confirmation box in the vertical half opposite the
// callsign, on the side not occupied by inserts.
func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
box := QSOBox{
Enabled: true, W: 760, H: 220,
BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
Title: "Confirming QSO with {qso.callsign}",
Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"},
Footer: "{qso.qsl_msg}",
}
box.Y = cardH - box.H - 110
if zone.y+zone.h/2 > cardH/2 {
box.Y = 90
}
box.X = 64
if overlapsAny(pxRect{x: box.X, y: box.Y, w: box.W, h: box.H}, occupied) {
box.X = cardW - box.W - 64
}
return box
}
// quietestCorner returns the top-left position for the country block in the
// calmest unoccupied corner.
func quietestCorner(p PhotoAnalysis, crop Crop, occupied []pxRect) (float64, float64) {
const margin, blockW, blockH = 64.0, 320.0, 60.0
corners := []pxRect{
{x: margin, y: cardH - margin - blockH, w: blockW, h: blockH}, // bottom-left
{x: cardW - margin - blockW, y: cardH - margin - blockH, w: blockW, h: blockH},
{x: margin, y: margin, w: blockW, h: blockH},
{x: cardW - margin - blockW, y: margin, w: blockW, h: blockH},
}
bestX, bestY := corners[0].x, corners[0].y
bestDetail := math.Inf(1)
for _, corner := range corners {
if overlapsAny(corner, occupied) {
continue
}
d := regionDetail(p, crop, corner)
if d < bestDetail {
bestDetail = d
bestX, bestY = corner.x, corner.y
}
}
return bestX, bestY
}
// regionDetail averages cell detail under a card-space rect.
func regionDetail(p PhotoAnalysis, crop Crop, zone pxRect) float64 {
var sum float64
n := 0
for r := 0; r < gridRows; r++ {
for c := 0; c < gridCols; c++ {
cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
if cell.w <= 0 || cell.h <= 0 {
continue
}
if cell.x+cell.w/2 >= zone.x && cell.x+cell.w/2 <= zone.x+zone.w &&
cell.y+cell.h/2 >= zone.y && cell.y+cell.h/2 <= zone.y+zone.h {
sum += p.Cells[r][c].Detail
n++
}
}
}
if n == 0 {
return 1
}
return sum / float64(n)
}
func overlapsAny(a pxRect, rects []pxRect) bool {
for _, b := range rects {
if a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y {
return true
}
}
return false
}
func clamp(v, lo, hi float64) float64 {
if hi < lo {
return lo
}
return math.Max(lo, math.Min(hi, v))
}