Files
backend_fiesta/utils/geo.go
2026-09-16 11:52:35 +05:30

143 lines
4.7 KiB
Go
Raw 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
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
}
if len(raw) > 1 {
kept++
}
seen[raw] = true
tokens = append(tokens, raw)
}
return tokens
}
// 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
}