Search By Location

* Add a world cities database with type-ahead search on the Member Directory.
* Users can search for a known city to order users by distance from that city
  rather than from their own configured location on their settings page.
* Users must opt-in their own location before this feature may be used, in order
  to increase adoption of the location feature and to enforce fairness.
* The `nonshy setup locations` command can import the world cities database.
This commit is contained in:
Noah Petherbridge 2024-08-03 14:54:22 -07:00
parent 6ca94cb926
commit b8be14ea8d
12 changed files with 2842 additions and 22 deletions

View File

@ -221,6 +221,30 @@ func main() {
},
},
},
{
Name: "setup",
Usage: "setup and data import functions for the website",
Subcommands: []*cli.Command{
{
Name: "locations",
Usage: "import the database of world city locations from simplemaps.com",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Required: true,
Usage: "the input worldcities.csv from simplemaps, with required headers: id, city, lat, lng, country, iso2",
},
},
Action: func(c *cli.Context) error {
initdb(c)
filename := c.String("input")
return models.InitializeWorldCities(filename)
},
},
},
},
{
Name: "backfill",
Usage: "One-off maintenance tasks and data backfills for database migrations",

View File

@ -36,6 +36,7 @@ func Search() http.HandlerFunc {
isCertified = r.FormValue("certified")
username = r.FormValue("name") // username search
searchTerm = r.FormValue("search") // profile text search
citySearch = r.FormValue("wcs")
gender = r.FormValue("gender")
orientation = r.FormValue("orientation")
maritalStatus = r.FormValue("marital_status")
@ -96,6 +97,24 @@ func Search() http.HandlerFunc {
log.Error("RefreshGeoIP: %s", err)
}
// Are they doing a Location search (from world city typeahead)?
var city *models.WorldCities
if citySearch != "" {
sort = "distance"
// Require the current user to have THEIR location set, for fairness.
if myLocation.Source == models.LocationSourceNone {
session.FlashError(w, r, "You must set your own location before you can search for others by their location.")
} else {
// Look up the coordinates of their search.
city, err = models.FindWorldCity(citySearch)
if err != nil {
session.FlashError(w, r, "Location search: no match was found for '%s', please use one of the exact search results from the type-ahead on the Location field.", citySearch)
citySearch = "" // null out their search
}
}
}
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
@ -138,6 +157,7 @@ func Search() http.HandlerFunc {
HereFor: hereFor,
ProfileText: search,
Certified: certifiedOnly,
NearCity: city,
NotCertified: isCertified == "false",
InnerCircle: isCertified == "circle",
ShyAccounts: isCertified == "shy",
@ -183,6 +203,7 @@ func Search() http.HandlerFunc {
"HereFor": hereFor,
"EmailOrUsername": username,
"Search": searchTerm,
"City": citySearch,
"AgeMin": ageMin,
"AgeMax": ageMax,
"FriendSearch": friendSearch,
@ -208,7 +229,7 @@ func Search() http.HandlerFunc {
// Current user's location setting.
"MyLocation": myLocation,
"GeoIPInsights": insights,
"DistanceMap": models.MapDistances(currentUser, users),
"DistanceMap": models.MapDistances(currentUser, city, users),
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -49,3 +49,17 @@ func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
w.WriteHeader(statusCode)
w.Write(buf)
}
// SendRawJSON response without the standard API wrapper.
func SendRawJSON(w http.ResponseWriter, statusCode int, v interface{}) {
buf, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
w.Write(buf)
}

View File

@ -0,0 +1,34 @@
package api
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/models"
)
// WorldCities API searches the location database for a world city location.
func WorldCities() http.HandlerFunc {
type Result struct {
ID uint64 `json:"id"`
Value string `json:"value"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var query = r.FormValue("query")
if query == "" {
SendRawJSON(w, http.StatusOK, []Result{})
return
}
result, err := models.SearchWorldCities(query)
if err != nil {
SendRawJSON(w, http.StatusInternalServerError, []Result{{
ID: 1,
Value: err.Error(),
}})
return
}
SendRawJSON(w, http.StatusOK, result)
})
}

View File

@ -34,4 +34,5 @@ func AutoMigrate() {
DB.AutoMigrate(&ChangeLog{})
DB.AutoMigrate(&IPAddress{})
DB.AutoMigrate(&PushNotification{})
DB.AutoMigrate(&WorldCities{})
}

View File

@ -259,6 +259,7 @@ type UserSearch struct {
MaritalStatus string
HereFor string
ProfileText *Search
NearCity *WorldCities
Certified bool
NotCertified bool
InnerCircle bool
@ -289,7 +290,7 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
)
// Sort by distance? Requires PostgreSQL.
if pager.Sort == "distance" {
if pager.Sort == "distance" || search.NearCity != nil {
if !config.Current.Database.IsPostgres {
return users, errors.New("ordering by distance requires PostgreSQL with the PostGIS extension")
}
@ -299,6 +300,15 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
return users, errors.New("can not sort members by distance because your location is not known")
}
// Which location to search from?
var (
latitude, longitude = myLocation.Latitude, myLocation.Longitude
)
if search.NearCity != nil {
latitude = search.NearCity.Latitude
longitude = search.NearCity.Longitude
}
// Only query for users who have locations.
joins = "JOIN user_locations ON (user_locations.user_id = users.id)"
wheres = append(wheres,
@ -311,7 +321,7 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
pager.Sort = fmt.Sprintf(`ST_Distance(
ST_MakePoint(user_locations.longitude, user_locations.latitude)::geography,
ST_MakePoint(%f, %f)::geography)`,
myLocation.Longitude, myLocation.Latitude,
longitude, latitude,
)
}

View File

@ -69,11 +69,15 @@ func truncate(f float64) float64 {
}
// MapDistances computes human readable distances between you and the set of users.
func MapDistances(currentUser *User, others []*User) DistanceMap {
//
// The fromCity attribute is optional. If non-nil, the distance will be computed in
// relation to that city's location instead of the current user.
func MapDistances(currentUser *User, fromCity *WorldCities, others []*User) DistanceMap {
// Get all the distances we can.
var (
result = DistanceMap{}
myDist = GetUserLocation(currentUser.ID)
result = DistanceMap{}
myDist = GetUserLocation(currentUser.ID)
latitude, longitude = myDist.Latitude, myDist.Longitude
// dist = map[uint64]*UserLocation{}
userIDs = []uint64{}
)
@ -81,6 +85,12 @@ func MapDistances(currentUser *User, others []*User) DistanceMap {
userIDs = append(userIDs, user.ID)
}
// Are we ordering from a different city?
if fromCity != nil {
latitude = fromCity.Latitude
longitude = fromCity.Longitude
}
// Query for their UserLocation objects, if exists.
var ul = []*UserLocation{}
res := DB.Where("user_id IN ?", userIDs).Find(&ul)
@ -92,7 +102,7 @@ func MapDistances(currentUser *User, others []*User) DistanceMap {
// Map them out.
for _, row := range ul {
km, mi := HaversineDistance(
myDist.Latitude, myDist.Longitude,
latitude, longitude,
row.Latitude, row.Longitude,
)
result[row.UserID] = fmt.Sprintf("%.1fkm / %.1fmi", km, mi)

152
pkg/models/world_cities.go Normal file
View File

@ -0,0 +1,152 @@
package models
import (
"encoding/csv"
"errors"
"fmt"
"os"
"strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/log"
)
// WorldCities is a lookup database of cities/countries to their geo coordinates.
//
// It aids the search by location feature of the member directory. Data obtained from simplemaps.com
type WorldCities struct {
ID uint64 `gorm:"primary_key"`
City string
State string
Country string
ISO string
Canonical string `gorm:"index"` // the full City, State, ISO string
Latitude float64
Longitude float64
}
// SearchWorldCities handles a type-ahead search query.
func SearchWorldCities(query string) ([]*WorldCities, error) {
var (
like = fmt.Sprintf("%%%s%%", strings.ToLower(query))
result = []*WorldCities{}
tx = DB.Model(&WorldCities{}).Where(
"canonical ILIKE ?",
like,
).Limit(50).Scan(&result)
)
return result, tx.Error
}
// FindWorldCity looks up a world city by its Canonical name from typeahead search.
func FindWorldCity(canonical string) (*WorldCities, error) {
var (
city *WorldCities
result = DB.Model(&WorldCities{}).Where("canonical = ?", canonical).First(&city)
)
if result.Error != nil {
return nil, result.Error
}
return city, nil
}
// InitializeWorldCities from an input CSV spreadsheet from simplemaps.com.
//
// This will wipe and reset the WorldCities table from the imported spreadsheet.
//
// The CSV file needs at least the columns: id, city, admin_name, country, iso2, lat, lng.
//
// The CSV can be downloaded from: https://simplemaps.com/data/world-cities
func InitializeWorldCities(csvFilename string) error {
fh, err := os.Open(csvFilename)
if err != nil {
return err
}
reader := csv.NewReader(fh)
// Read the header row and find the required fields.
fields := map[string]*int{
"id": nil,
"city": nil,
"admin_name": nil,
"country": nil,
"iso2": nil,
"lat": nil,
"lng": nil,
}
header, err := reader.Read()
if err != nil {
return fmt.Errorf("CSV header row: %s", err)
}
// Map the header fields to their index.
for i, field := range header {
if _, ok := fields[field]; ok {
fields[field] = &i
}
}
// Sanity check that all header fields are found.
var errHeader error
for k, v := range fields {
if v == nil {
log.Error("WorldCities CSV: required header %s not found", k)
errHeader = errors.New("missing one or more required csv headers")
}
}
if errHeader != nil {
return errHeader
}
// Action!
// Flush the existing WorldCities table.
if tx := DB.Exec("DELETE FROM world_cities"); tx.Error != nil {
return fmt.Errorf("deleting world_cities: %s", tx.Error)
}
// Populate the database.
for {
row, err := reader.Read()
if err != nil {
break
}
// Cast data types.
id, err1 := strconv.ParseUint(row[*fields["id"]], 10, 64)
lat, err2 := strconv.ParseFloat(row[*fields["lat"]], 64)
lon, err3 := strconv.ParseFloat(row[*fields["lng"]], 64)
if err1 != nil || err2 != nil || err3 != nil {
return fmt.Errorf("row %+v failed to cast one or more data types: id (%s), lat (%s), lng (%s)", row, err1, err2, err3)
}
record := &WorldCities{
ID: id,
City: row[*fields["city"]],
State: row[*fields["admin_name"]],
Country: row[*fields["country"]],
ISO: row[*fields["iso2"]],
Latitude: lat,
Longitude: lon,
}
// Canonical string for search.
var canonical string
if record.State != "" {
canonical = fmt.Sprintf("%s, %s, %s", record.City, record.State, record.ISO)
} else {
canonical = fmt.Sprintf("%s, %s", record.City, record.ISO)
}
record.Canonical = canonical
result := DB.Create(&record)
if result.Error != nil {
return fmt.Errorf("create row %+v: %s", record, result.Error)
}
log.Info("WorldCities: loaded %s, %s", record.City, record.Country)
}
return nil
}

View File

@ -121,6 +121,7 @@ func New() http.Handler {
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
mux.Handle("GET /v1/world-cities", middleware.LoginRequired(api.WorldCities()))
mux.Handle("POST /v1/barertc/report", barertc.Report())
mux.Handle("POST /v1/barertc/profile", barertc.Profile())

2
web/static/js/jquery-3.7.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -39,21 +39,36 @@
Showing you <i class="fa fa-location-dot mr-1"></i> <strong>Who's Nearby.</strong>
</p>
<!-- Show options to refresh their location -->
<p>
{{if eq .MyLocation.Source "geoip"}}
You had set your location to update automatically by your IP address <small>(currently {{.GeoIPInsights.Short}}).</small>
<a href="/settings#location">Change your location settings?</a>
{{else if eq .MyLocation.Source "gps"}}
You once set your location via your device's GPS coordinates.
<a href="/settings#location" id="gpsRefresh">Refresh to your current location now?</a>
{{else if eq .MyLocation.Source "pin"}}
You had set your location by dropping a pin on a map.
<a href="/settings#location">Update your location?</a>
{{else if eq .MyLocation.Source ""}}
You will need to <a href="/settings#location">set your location</a> first before we can sort people by distance from you.
<!-- Was it a manual city search? -->
{{if .City}}
<!-- If the current user has no location set, lead the way. -->
{{if not .MyLocation.Source}}
<p>
You will need to <a href="/settings#location">set your own location</a> first before you can search for other members by
their location. It's only fair!
</p>
{{else}}
<p>
You are searching for profiles near {{.City}}.
</p>
{{end}}
</p>
{{else}}
<!-- Show options to refresh their location -->
<p>
{{if eq .MyLocation.Source "geoip"}}
You had set your location to update automatically by your IP address <small>(currently {{.GeoIPInsights.Short}}).</small>
<a href="/settings#location">Change your location settings?</a>
{{else if eq .MyLocation.Source "gps"}}
You once set your location via your device's GPS coordinates.
<a href="/settings#location" id="gpsRefresh">Refresh to your current location now?</a>
{{else if eq .MyLocation.Source "pin"}}
You had set your location by dropping a pin on a map.
<a href="/settings#location">Update your location?</a>
{{else if eq .MyLocation.Source ""}}
You will need to <a href="/settings#location">set your location</a> first before we can sort people by distance from you.
{{end}}
</p>
{{end}}
</div>
{{else if eq .Certified "shy"}}
<div class="notification is-success is-light content">
@ -153,7 +168,7 @@
</div>
</div>
<div class="column is-half pl-1">
<div class="column px-1">
<div class="field">
<label class="label" for="search">Profile text:</label>
<input type="text" class="input"
@ -168,6 +183,16 @@
</div>
</div>
<div class="column pl-1">
<div class="field">
<label class="label" for="wcs">Location: <span class="tag is-success">New!</span></label>
<input type="text" class="input"
name="wcs" id="wcs"
autocomplete="off"
value="{{$Root.City}}">
</div>
</div>
</div>
<div class="columns is-centered">
@ -470,3 +495,78 @@
</form>
</div>
{{end}}
{{define "scripts"}}
<!-- Typeahead search for City -->
<script type="text/javascript" src="/static/js/jquery-3.7.1.min.js"></script>
<script type="text/javascript" src="/static/js/typeahead.bundle.js"></script>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', (e) => {
let backend = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: '/v1/world-cities?query=%QUERY',
wildcard: '%QUERY'
},
});
let $searchField = $("#wcs");
$searchField.typeahead({
autoselect: true
}, {
items: 4,
name: 'location',
displayKey: 'Canonical',
source: backend,
});
});
</script>
<style type="text/css">
/*****************************
* Twitter Typeahead Styling *
*****************************/
.twitter-typeahead {
width: 100%;
}
.tt-hint {
color: #999;
}
.tt-menu {
width: 300px;
margin-top: 4px;
padding: 4px 0;
background-color: #fff;
border: 1px solid #ccc;
border: 1px solid rgba(0, 0, 0, 0.2);
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
-webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
-moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
box-shadow: 0 5px 10px rgba(0,0,0,.2);
}
.tt-suggestion {
padding: 3px 20px;
line-height: 24px;
color: #000;
}
.tt-suggestion:hover {
background-color: #ddd;
}
.tt-suggestion p {
margin: 0;
}
.tt-suggestion.tt-cursor {
color: #fff;
background-color: #0097cf;
}
</style>
{{end}}