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, 0–1 Luma float64 // mean relative luminance, 0–1 } // 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, 0–1 AvgLuma float64 Warmth float64 // mean (R−B), 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 0–1 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)) }