Files
backend_fiesta/utils/geo.go
2026-09-16 12:17:39 +05:30

168 lines
5.6 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 utils
import (
"math"
"strconv"
"strings"
"time"
)
// ParseLatLng reads the coordinate strings this schema stores — customers,
// customerlocations and tenantlocations all hold latitude/longitude as text
// — and says whether they name a real place.
//
// "0,0" is rejected along with blanks: nothing on this platform is in the
// Gulf of Guinea, and it is what an empty map picker saves.
func ParseLatLng(lat, lng string) (float64, float64, bool) {
la, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64)
lo, err2 := strconv.ParseFloat(strings.TrimSpace(lng), 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
if la < -90 || la > 90 || lo < -180 || lo > 180 || (la == 0 && lo == 0) {
return 0, 0, false
}
return la, lo, true
}
// HaversineKm is the great-circle distance between two points.
func HaversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const earthRadiusKm = 6371.0
toRad := func(d float64) float64 { return d * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLng := toRad(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
return 2 * earthRadiusKm * math.Asin(math.Sqrt(a))
}
// OpenNow reads tenantlocations.opentime/closetime ("09:00", "21:30",
// "9:00 AM") against the wall clock. Unparsable or blank hours are treated as
// open: a shop that never filled the field in should not vanish from the
// list, and the ordering flow re-checks at checkout anyway.
func OpenNow(open, closeAt string, now time.Time) bool {
o, ok1 := parseClock(open)
c, ok2 := parseClock(closeAt)
if !ok1 || !ok2 || o == c {
return true
}
cur := now.Hour()*60 + now.Minute()
if o < c {
return cur >= o && cur < c
}
// Past midnight: "20:00" – "02:00".
return cur >= o || cur < c
}
func parseClock(s string) (int, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
for _, layout := range []string{"15:04", "15:04:05", "3:04 PM", "3:04PM", "03:04 PM", "15.04"} {
if t, err := time.Parse(layout, strings.ToUpper(s)); err == nil {
return t.Hour()*60 + t.Minute(), true
}
}
return 0, false
}
// SearchTokens splits a label into the words worth matching on: lowercased,
// punctuation stripped, pack-size noise dropped. "Milk Bikis 100g" →
// ["milk", "bikis"]; the size is matched separately, if at all.
//
// A single character is kept when it follows a word, because in this
// catalogue that character is often the whole product: the "G" in "Parle G",
// the "K" in "Special K". Dropping it made "Parle G" score the same against
// "Parle-G Original Glucose Biscuits" as against "Parle Monaco Classic", and
// the tie went to Monaco. It is still dropped when it stands alone — a
// one-letter label is not a search — and bare multipliers ("2 x 50gm") are
// never words.
func SearchTokens(label string) []string {
var tokens []string
seen := make(map[string]bool)
kept := 0 // multi-character tokens so far: a lone letter needs one
var filler []string // packaging words, kept only if nothing else survives
for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
}) {
if isPackSize(raw) || seen[raw] {
continue
}
if len(raw) < 2 && (kept == 0 || isMultiplier(raw)) {
continue
}
seen[raw] = true
if isPackaging(raw) {
filler = append(filler, raw)
continue
}
if len(raw) > 1 {
kept++
}
tokens = append(tokens, raw)
}
// "Dettol bottle pack" is a scan of Dettol. Only when the label is nothing
// but packaging does that packaging become the search.
if len(tokens) == 0 {
return filler
}
return tokens
}
// isPackaging is what the label says about the wrapper rather than the
// product: "Dettol bottle pack", "Parle G biscuit pack". Lens reads these off
// the packet and no catalogue name carries them, so every one of them used to
// be a word the row had to contain — and "Dettol bottle pack" found no Dettol
// at all. Treated like pack sizes: real words, just not the product's name.
func isPackaging(tok string) bool {
switch tok {
case "pack", "packs", "packet", "packets", "bottle", "bottles",
"box", "boxes", "jar", "jars", "tin", "tins", "pouch", "pouches",
"carton", "cartons", "sachet", "sachets", "combo", "refill":
return true
}
return false
}
// isMultiplier is the "x" of "2 x 50gm" and the "n" of a multipack — a single
// character that joins sizes rather than naming a product.
func isMultiplier(tok string) bool {
return tok == "x" || tok == "n"
}
// FoldSeparators turns every run of punctuation into one space, so a label
// typed without the brand's own punctuation still matches it: "Parle G" and
// "Parle-G" both fold to "parle g". Lens reads letterforms off a packet, and
// people type what they see, so the hyphen is not reliably either present or
// absent on the way in.
func FoldSeparators(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9')
}), " ")
}
// TightenLabel removes separators outright rather than folding them, catching
// the other way people write a hyphenated name: "ParleG" against "Parle-G".
func TightenLabel(s string) string {
return strings.ReplaceAll(FoldSeparators(s), " ", "")
}
// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit
// glued on, or a bare number.
func isPackSize(tok string) bool {
digits := 0
for digits < len(tok) && tok[digits] >= '0' && tok[digits] <= '9' {
digits++
}
if digits == 0 {
return false
}
switch tok[digits:] {
case "", "g", "gm", "gms", "kg", "ml", "l", "ltr", "pcs", "pc", "x", "n":
return true
}
return false
}