diff --git a/server/go.mod b/server/go.mod index ed1b02545..f57f350b3 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( github.com/99designs/gqlgen v0.17.43 + github.com/JamesLMilner/quadtree-go v0.0.0-20191212211504-d12870ffe403 github.com/dustin/go-humanize v1.0.1 github.com/eukarya-inc/jpareacode v1.0.1-0.20240314080116-ae89cfd85c6a github.com/go-playground/validator/v10 v10.16.0 diff --git a/server/go.sum b/server/go.sum index 4ccfd4244..10d19721b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -34,6 +34,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.45.0/go.mod h1:qkFPtMouQjW5ugdHIOthiTbweVHUTqbS0Qsu55KqXks= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/JamesLMilner/quadtree-go v0.0.0-20191212211504-d12870ffe403 h1:d0humYgeOKZG99ab6SQl+sDsO6W9Pl5UuO515QT/IxQ= +github.com/JamesLMilner/quadtree-go v0.0.0-20191212211504-d12870ffe403/go.mod h1:53pPERNR0+5DxLnYw7RCA9DS+cyETfT/knWWoNL/F1s= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= diff --git a/server/govpolygon/handler.go b/server/govpolygon/handler.go index 0880260ea..a6c51a674 100644 --- a/server/govpolygon/handler.go +++ b/server/govpolygon/handler.go @@ -8,13 +8,17 @@ import ( "io" "net/http" "path/filepath" + "strconv" "sync" "time" + "github.com/eukarya-inc/jpareacode" + "github.com/eukarya-inc/jpareacode/jpareacodepref" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/reearth/reearthx/log" "github.com/reearth/reearthx/util" + "github.com/samber/lo" ) const dirpath = "govpolygondata" @@ -28,6 +32,7 @@ type Handler struct { httpClient *http.Client lock sync.RWMutex geojson []byte + qt *Quadtree updateIfNotExists bool updatedAt time.Time } @@ -44,16 +49,21 @@ func New(gqlEndpoint string, updateIfNotExists bool) *Handler { func (h *Handler) Route(g *echo.Group) *Handler { g.Use(middleware.CORS(), middleware.Gzip()) g.GET("/plateaugovs.geojson", h.GetGeoJSON) + g.GET("/geocoding", h.FindCodeFromLngLat) // g.GET("/update", h.Update, errorLogger) return h } -func (h *Handler) GetGeoJSON(c echo.Context) error { +func (h *Handler) updateIfNeed(c echo.Context) { if h.updateIfNotExists && h.geojson == nil { if err := h.Update(c); err != nil { log.Errorfc(c.Request().Context(), "govpolygon: fail to init: %v", err) } } +} + +func (h *Handler) GetGeoJSON(c echo.Context) error { + h.updateIfNeed(c) h.lock.RLock() defer h.lock.RUnlock() @@ -98,6 +108,8 @@ func (h *Handler) Update(c echo.Context) error { return fmt.Errorf("failed to marshal geojson: %w", err) } + h.qt = NewQuadtree(g.Features) + if !initial { h.lock.Lock() defer h.lock.Unlock() @@ -198,3 +210,50 @@ func (h *Handler) getCityNames(ctx context.Context) ([]string, error) { return names, nil } + +func (h *Handler) FindCodeFromLngLat(c echo.Context) error { + h.updateIfNeed(c) + + lngs, lats := c.QueryParam("lng"), c.QueryParam("lat") + if lngs == "" || lats == "" { + return c.JSON(http.StatusBadRequest, "lng and lat are required") + } + + lng, err := strconv.ParseFloat(lngs, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, "invalid lng") + } + + lat, err := strconv.ParseFloat(lats, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, "invalid lat") + } + + h.lock.RLock() + defer h.lock.RUnlock() + if h.qt == nil { + return c.JSON(http.StatusNotFound, "not found") + } + + code, _ := h.qt.Find(lng, lat) + city := jpareacode.CityByCodeString(code) + + if city == nil { + return c.JSON(http.StatusOK, map[string]any{ + "lng": lng, + "lat": lat, + }) + } + + return c.JSON(http.StatusOK, map[string]any{ + "lng": lng, + "lat": lat, + "pref": jpareacodepref.PrefectureNameByCodeInt(city.PrefCode), + "prefCode": jpareacodepref.FormatPrefectureCode(city.PrefCode), + "city": lo.EmptyableToPtr(city.CityName), + "cityCode": lo.EmptyableToPtr(jpareacode.FormatCityCode(city.CityCode)), + "ward": lo.EmptyableToPtr(city.WardName), + "wardCode": lo.EmptyableToPtr(jpareacode.FormatCityCode(city.WardCode)), + "code": jpareacode.FormatCityCode(city.Code()), + }) +} diff --git a/server/govpolygon/processor.go b/server/govpolygon/processor.go index 8bcc5bca5..a568b5a61 100644 --- a/server/govpolygon/processor.go +++ b/server/govpolygon/processor.go @@ -37,6 +37,14 @@ func (p *Processor) ComputeGeoJSON(ctx context.Context, names []string) (*geojso } func computeGeojsonFeatures(features []*geojson.Feature, names []string) (*geojson.FeatureCollection, []string) { + if len(names) == 0 { + fc := geojson.NewFeatureCollection() + for _, f := range features { + fc.AddFeature(f) + } + return fc, nil + } + nameMap := map[string]struct{}{} notfound := map[string]struct{}{} citiesWithWards := map[string]struct{}{} diff --git a/server/govpolygon/quadtree.go b/server/govpolygon/quadtree.go new file mode 100644 index 000000000..0856546ff --- /dev/null +++ b/server/govpolygon/quadtree.go @@ -0,0 +1,97 @@ +package govpolygon + +import ( + "github.com/JamesLMilner/quadtree-go" + geojson "github.com/paulmach/go.geojson" +) + +type Quadtree struct { + qt *quadtree.Quadtree + ft map[quadtree.Bounds]string +} + +func NewQuadtree(f []*geojson.Feature) *Quadtree { + ft := map[quadtree.Bounds]string{} + qt := &quadtree.Quadtree{ + MaxObjects: 10, + MaxLevels: 100, + Objects: make([]quadtree.Bounds, 0), + Nodes: make([]quadtree.Quadtree, 0), + } + + for _, f := range f { + b, ok := bounds(f.Geometry) + if !ok { + continue + } + + qt.Insert(b) + ft[b] = f.Properties["code"].(string) + } + + return &Quadtree{ + qt: qt, + ft: ft, + } +} + +func (q *Quadtree) Find(lng, lat float64) (string, bool) { + res := q.qt.RetrieveIntersections(quadtree.Bounds{ + X: lng, + Y: lat, + }) + if len(res) == 0 { + return "", false + } + + r, ok := q.ft[res[0]] + return r, ok +} + +func bounds(g *geojson.Geometry) (b quadtree.Bounds, _ bool) { + if !g.IsMultiPolygon() && !g.IsPolygon() { + return + } + + if g.IsPolygon() { + g = &geojson.Geometry{ + Type: "MultiPolygon", + MultiPolygon: [][][][]float64{g.Polygon}, + } + } + + minlat := -1.0 + minlng := -1.0 + maxlat := -1.0 + maxlng := -1.0 + + for _, polygon := range g.MultiPolygon { + for _, ring := range polygon { + for _, p := range ring { + lng := p[0] + lat := p[1] + + if minlat == -1 || lat < minlat { + minlat = lat + } + if minlng == -1 || lng < minlng { + minlng = lng + } + + if maxlat == -1 || lat > maxlat { + maxlat = lat + } + if maxlng == -1 || lng > maxlng { + maxlng = lng + } + } + } + } + + return quadtree.Bounds{ + X: minlng, + Y: minlat, + Width: maxlng - minlng, + Height: maxlat - minlat, + }, true +} diff --git a/server/govpolygon/quadtree_test.go b/server/govpolygon/quadtree_test.go new file mode 100644 index 000000000..5fd022b1e --- /dev/null +++ b/server/govpolygon/quadtree_test.go @@ -0,0 +1,33 @@ +package govpolygon + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQuadtree(t *testing.T) { + p := NewProcessor(filepath.Join(dirpath, "japan_city.geojson")) + ctx := context.Background() + f, _, err := p.ComputeGeoJSON(ctx, nil) + assert.NoError(t, err) + + q := NewQuadtree(f.Features) + res, ok := q.Find(139.760296, 35.686067) + assert.True(t, ok) + assert.Equal(t, "13101", res) +} + +func BenchmarkQuadtree(b *testing.B) { + p := NewProcessor(filepath.Join(dirpath, "japan_city.geojson")) + ctx := context.Background() + f, _, _ := p.ComputeGeoJSON(ctx, nil) + q := NewQuadtree(f.Features) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = q.Find(139.760296, 35.686067) + } +}