├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── dependabot_auto_merge.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── LICENSE ├── Makefile ├── Makefile-common.mk ├── README.md ├── cmd └── geohash │ └── geohash.go ├── geohash.go ├── geohash_benchmark_test.go ├── geohash_test.go ├── go.mod ├── go.sum ├── util.go └── util_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "08:00" 8 | timezone: "Europe/Paris" 9 | allow: 10 | - dependency-type: "direct" 11 | - dependency-type: "indirect" 12 | - dependency-type: "all" 13 | reviewers: 14 | - "pierrre" 15 | - package-ecosystem: github-actions 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | time: "08:00" 20 | timezone: "Europe/Paris" 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | jobs: 3 | ci: 4 | runs-on: ubuntu-24.04 5 | timeout-minutes: 10 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-go@v5 9 | with: 10 | go-version: '1.24' 11 | check-latest: true 12 | - name: "Run CI" 13 | run: make --warn-undefined-variables --no-print-directory ci 14 | - name: "Upload coverage" 15 | uses: actions/upload-artifact@v4 16 | with: 17 | name: coverage 18 | path: coverage.* 19 | -------------------------------------------------------------------------------- /.github/workflows/dependabot_auto_merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --squash "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /coverage.* 3 | *.pprof 4 | *.test 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - asasalint 6 | - asciicheck 7 | - bidichk 8 | - bodyclose 9 | - containedctx 10 | - contextcheck 11 | - copyloopvar 12 | - depguard 13 | - dogsled 14 | - dupl 15 | - durationcheck 16 | - errcheck 17 | - errchkjson 18 | - errname 19 | - errorlint 20 | - exhaustive 21 | - forbidigo 22 | - forcetypeassert 23 | - gocheckcompilerdirectives 24 | - gochecksumtype 25 | - gocritic 26 | - gocyclo 27 | - godot 28 | - goprintffuncname 29 | - gosec 30 | - govet 31 | - grouper 32 | - importas 33 | - inamedparam 34 | - ineffassign 35 | - interfacebloat 36 | - intrange 37 | - loggercheck 38 | - makezero 39 | - mirror 40 | - misspell 41 | - nakedret 42 | - nestif 43 | - nilerr 44 | - nilnil 45 | - noctx 46 | - nolintlint 47 | - nosprintfhostport 48 | - perfsprint 49 | - prealloc 50 | - predeclared 51 | - promlinter 52 | - reassign 53 | - revive 54 | - rowserrcheck 55 | - sloglint 56 | - sqlclosecheck 57 | - staticcheck 58 | - testableexamples 59 | - thelper 60 | - unconvert 61 | - unparam 62 | - unused 63 | - usestdlibvars 64 | - usetesting 65 | - wastedassign 66 | - whitespace 67 | - wrapcheck 68 | settings: 69 | depguard: 70 | rules: 71 | main: 72 | files: 73 | - $all 74 | deny: 75 | - pkg: unsafe 76 | desc: "it's not safe" 77 | gocritic: 78 | enabled-tags: 79 | - experimental 80 | - diagnostic 81 | - opinionated 82 | - performance 83 | - style 84 | disabled-checks: 85 | - paramTypeCombine # Some false positive. 86 | - whyNoLint # We already have the nolintlint linter. 87 | gocyclo: 88 | min-complexity: 10 89 | govet: 90 | enable-all: true 91 | disable: 92 | - fieldalignment # Too many false positive. 93 | grouper: 94 | import-require-single-import: true 95 | import-require-grouping: true 96 | nolintlint: 97 | require-explanation: true 98 | require-specific: true 99 | allow-no-explanation: 100 | - errcheck 101 | - misspell 102 | allow-unused: false 103 | revive: 104 | rules: 105 | - name: exported 106 | disabled: false 107 | arguments: 108 | - disableStutteringCheck 109 | - name: unused-parameter # It's OK. 110 | disabled: true 111 | staticcheck: 112 | checks: 113 | - all 114 | exclusions: 115 | generated: lax 116 | formatters: 117 | enable: 118 | - gofmt 119 | - gofumpt 120 | - goimports 121 | exclusions: 122 | generated: lax 123 | issues: 124 | max-issues-per-linter: 0 125 | max-same-issues: 0 126 | output: 127 | sort-order: 128 | - file 129 | - linter 130 | - severity 131 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pierrre 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 Pierre Durand 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include Makefile-common.mk 2 | -------------------------------------------------------------------------------- /Makefile-common.mk: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL=all 2 | .DELETE_ON_ERROR: 3 | 4 | NULL:= 5 | SPACE:=$(NULL) $(NULL) 6 | 7 | .PHONY: all 8 | all: build test lint 9 | 10 | CI?=false 11 | ifeq ($(CI),true) 12 | VERBOSE?=true 13 | TEST_FULLPATH?=true 14 | TEST_COVER?=true 15 | endif 16 | 17 | VERBOSE?=false 18 | ifeq ($(VERBOSE),true) 19 | VERBOSE_FLAG=$(SPACE)-v 20 | else 21 | VERBOSE_FLAG= 22 | endif 23 | 24 | VERSION?=$(shell (git describe --tags --exact-match 2> /dev/null || git rev-parse HEAD) | sed "s/^v//") 25 | .PHONY: version 26 | version: 27 | @echo $(VERSION) 28 | 29 | GO?=go 30 | GO_RUN=$(GO) run$(VERBOSE_FLAG) 31 | GO_GET=$(GO) get$(VERBOSE_FLAG) 32 | GO_LIST=$(GO) list$(VERBOSE_FLAG) 33 | GO_MOD=$(GO) mod 34 | GO_TOOL=$(GO) tool 35 | GO_TOOL_COVER=$(GO_TOOL) cover 36 | 37 | GO_MODULE=$(shell $(GO_LIST) -m) 38 | 39 | GO_TAGS?= 40 | ifneq ($(GO_TAGS),) 41 | GO_TAGS_FLAG=$(SPACE)-tags=$(GO_TAGS) 42 | else 43 | GO_TAGS_FLAG= 44 | endif 45 | 46 | BUILD_DIR=build 47 | .PHONY: build 48 | build: 49 | ifneq ($(wildcard ./cmd/*/*.go),) 50 | mkdir -p $(BUILD_DIR) 51 | $(GO) build$(VERBOSE_FLAG)$(GO_TAGS_FLAG) -ldflags="-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR) ./cmd/... 52 | endif 53 | 54 | TEST_FULLPATH?=false 55 | ifeq ($(TEST_FULLPATH),true) 56 | TEST_FULLPATH_FLAG=$(SPACE)-fullpath 57 | else 58 | TEST_FULLPATH_FLAG= 59 | endif 60 | TEST_COVER?=false 61 | ifeq ($(TEST_COVER),true) 62 | TEST_COVER_FLAGS=$(SPACE)-cover -coverprofile=coverage.out 63 | else 64 | TEST_COVER_FLAGS= 65 | endif 66 | TEST_COUNT?= 67 | ifneq ($(TEST_COUNT),) 68 | TEST_COUNT_FLAG=$(SPACE)-count=$(TEST_COUNT) 69 | else 70 | TEST_COUNT_FLAG= 71 | endif 72 | .PHONY: test 73 | test: 74 | $(GO) test$(VERBOSE_FLAG)$(TEST_FULLPATH_FLAG)$(GO_TAGS_FLAG)$(TEST_COVER_FLAGS)$(TEST_COUNT_FLAG) ./... 75 | ifeq ($(TEST_COVER),true) 76 | $(GO_TOOL_COVER) -func=coverage.out -o=coverage.txt 77 | ifeq ($(VERBOSE),true) 78 | cat coverage.txt 79 | endif 80 | $(GO_TOOL_COVER) -html=coverage.out -o=coverage.html 81 | endif 82 | 83 | .PHONY: generate 84 | generate:: 85 | $(GO) generate$(VERBOSE_FLAG) ./... 86 | 87 | .PHONY: lint 88 | lint: 89 | $(MAKE) golangci-lint 90 | $(MAKE) lint-rules 91 | $(MAKE) mod-tidy 92 | 93 | # version: 94 | # - tag: vX.Y.Z 95 | # - branch: master 96 | # - latest 97 | GOLANGCI_LINT_VERSION?=v2.1.6 98 | # Installation type: 99 | # - binary 100 | # - source 101 | GOLANGCI_LINT_TYPE?=binary 102 | 103 | ifeq ($(GOLANGCI_LINT_TYPE),binary) 104 | 105 | GOLANGCI_LINT_DIR=$(shell $(GO) env GOPATH)/pkg/golangci-lint/$(GOLANGCI_LINT_VERSION) 106 | GOLANGCI_LINT_BIN=$(GOLANGCI_LINT_DIR)/golangci-lint 107 | 108 | $(GOLANGCI_LINT_BIN): 109 | curl$(VERBOSE_FLAG) -fL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(GOLANGCI_LINT_DIR) $(GOLANGCI_LINT_VERSION) 110 | 111 | .PHONY: install-golangci-lint 112 | install-golangci-lint: $(GOLANGCI_LINT_BIN) 113 | 114 | else ifeq ($(GOLANGCI_LINT_TYPE),source) 115 | 116 | GOLANGCI_LINT_BIN=$(GO_RUN) github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) 117 | 118 | install-golangci-lint: 119 | 120 | endif 121 | 122 | GOLANGCI_LINT_RUN=$(GOLANGCI_LINT_BIN)$(VERBOSE_FLAG) run 123 | .PHONY: golangci-lint 124 | golangci-lint: install-golangci-lint 125 | ifeq ($(CI),true) 126 | $(GOLANGCI_LINT_RUN) 127 | else 128 | # Fix errors if possible. 129 | $(GOLANGCI_LINT_RUN) --fix 130 | endif 131 | 132 | .PHONY: golangci-lint-cache-clean 133 | golangci-lint-cache-clean: install-golangci-lint 134 | $(GOLANGCI_LINT_BIN) cache clean 135 | 136 | .PHONY: lint-rules 137 | CHECK_MISSING_FILE=@[ -e $(1) ] || (echo "$(1) file is missing" && false) 138 | lint-rules: 139 | # Disallowed files. 140 | @! find . -name ".DS_Store" | (grep "." && echo "Disallowed files") 141 | 142 | # Mandatory files. 143 | $(call CHECK_MISSING_FILE,.gitignore) 144 | $(call CHECK_MISSING_FILE,README.md) 145 | $(call CHECK_MISSING_FILE,LICENSE) 146 | $(call CHECK_MISSING_FILE,CODEOWNERS) 147 | $(call CHECK_MISSING_FILE,.github/dependabot.yml) 148 | $(call CHECK_MISSING_FILE,.github/workflows/ci.yml) 149 | $(call CHECK_MISSING_FILE,.github/workflows/dependabot_auto_merge.yml) 150 | $(call CHECK_MISSING_FILE,go.mod) 151 | $(call CHECK_MISSING_FILE,go.sum) 152 | $(call CHECK_MISSING_FILE,.golangci.yml) 153 | $(call CHECK_MISSING_FILE,Makefile) 154 | $(call CHECK_MISSING_FILE,Makefile-common.mk) 155 | 156 | # Don't use upper case letter in file and directory name. 157 | # The convention for separator in name is: 158 | # - file: "_" 159 | # - directory in "/cmd": "-" 160 | # - other directory: shouldn't be separated 161 | @! find . -name "*.go" | (grep "[[:upper:]]" && echo "Incorrect file name case") 162 | 163 | .PHONY: mod-update 164 | mod-update: 165 | $(GO_GET) -u all 166 | $(MAKE) mod-tidy 167 | 168 | .PHONY: mod-update-pierrre 169 | mod-update-pierrre: 170 | GOWORK=off $(GO_LIST) -m -u -json all | jq -r 'select(.Main==null and (.Path | startswith("github.com/pierrre/")) and .Update!=null) | .Path' | xargs -I {} -t $(GO_GET) -u {} 171 | $(MAKE) mod-tidy 172 | 173 | MOD_TIDY=$(GO_MOD) tidy$(VERBOSE_FLAG) 174 | .PHONY: mod-tidy 175 | mod-tidy: 176 | ifeq ($(CI),true) 177 | $(MOD_TIDY) -diff 178 | else 179 | $(MOD_TIDY) 180 | endif 181 | 182 | .PHONY: git-latest-release 183 | git-latest-release: 184 | @git tag --list --sort=v:refname --format="%(refname:short) => %(creatordate:short)" | tail -n 1 185 | 186 | .PHONY: clean 187 | clean: 188 | git clean -fdX 189 | go clean -cache -testcache 190 | $(MAKE) golangci-lint-cache-clean 191 | 192 | ifeq ($(CI),true) 193 | 194 | GITHUB_REF?=$(error missing GITHUB_REF) 195 | GITHUB_BRANCH=$(shell echo $(GITHUB_REF) | grep -Po "^refs\/heads/\K.+") 196 | GITHUB_TAG=$(shell echo $(GITHUB_REF) | grep -Po "^refs\/tags/\K.+") 197 | 198 | CI_LOG_GROUP_START=@echo "::group::$(1)" 199 | CI_LOG_GROUP_END=@echo "::endgroup::" 200 | 201 | .PHONY: ci 202 | ci:: 203 | $(call CI_LOG_GROUP_START,env) 204 | $(MAKE) ci-env 205 | $(call CI_LOG_GROUP_END) 206 | 207 | $(call CI_LOG_GROUP_START,build) 208 | $(MAKE) build 209 | $(call CI_LOG_GROUP_END) 210 | 211 | $(call CI_LOG_GROUP_START,test) 212 | $(MAKE) test 213 | $(call CI_LOG_GROUP_END) 214 | 215 | $(call CI_LOG_GROUP_START,lint) 216 | $(MAKE) lint 217 | $(call CI_LOG_GROUP_END) 218 | 219 | .PHONY: ci-env 220 | ci-env: 221 | env 222 | 223 | ifneq ($(GITHUB_TAG),) 224 | ci:: 225 | $(call CI_LOG_GROUP_START,tag) 226 | $(MAKE) ci-tag 227 | $(call CI_LOG_GROUP_END) 228 | 229 | .PHONY: ci-tag 230 | ci-tag: 231 | GOPROXY=proxy.golang.org $(GO_LIST) -x -m $(GO_MODULE)@$(GITHUB_TAG) 232 | endif 233 | 234 | endif # CI end 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Geohash 2 | 3 | A geohash library for Go (Golang) 4 | 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/pierrre/geohash.svg)](https://pkg.go.dev/github.com/pierrre/geohash) 6 | 7 | ## Features 8 | 9 | - Encode latitude/longitude to geohash 10 | - Decode geohash to latitude/longitude 11 | - Round a geohash box to a single location 12 | - [Commande-line tool](https://pkg.go.dev/github.com/pierrre/geohash/cmd/geohash) 13 | -------------------------------------------------------------------------------- /cmd/geohash/geohash.go: -------------------------------------------------------------------------------- 1 | /* 2 | Geohash command-line. 3 | 4 | # Usage 5 | 6 | Encode lat/lon to geohash: 7 | 8 | geohash 48.86,2.35 9 | 10 | u09tvqx 11 | 12 | Decode geohash to lat/lon: 13 | 14 | geohash u09tvqx 15 | 16 | 48.86,2.35 17 | 18 | Custom precision: 19 | 20 | geohash -precision=12 48.86,2.35 21 | 22 | u09tvqxnnuph 23 | 24 | Don't round: 25 | 26 | geohash -round=false u09tvqx 27 | 28 | 48.85963439941406,2.3503875732421875 29 | 30 | Multiple arguments: 31 | 32 | geohash 35.691015,139.766014 u09tvqx 33 | 34 | xn77h3qe0pmt 48.86,2.35 35 | 36 | Stdin: 37 | 38 | echo "u09tvqx" | geohash 39 | 40 | 48.86,2.35 41 | */ 42 | package main 43 | 44 | import ( 45 | "bufio" 46 | "flag" 47 | "fmt" 48 | "os" 49 | "strconv" 50 | "strings" 51 | 52 | "github.com/pierrre/geohash" 53 | ) 54 | 55 | var ( 56 | flagPrecision int 57 | flagRound bool 58 | ) 59 | 60 | func init() { 61 | flag.IntVar(&flagPrecision, "precision", 0, "Precision") 62 | flag.BoolVar(&flagRound, "round", true, "Round") 63 | flag.Parse() 64 | } 65 | 66 | func main() { 67 | if err := processSwitch(); err != nil { 68 | panic(err) 69 | } 70 | } 71 | 72 | func processSwitch() error { 73 | if flag.NArg() > 0 { 74 | return processArgs() 75 | } 76 | return processStdin() 77 | } 78 | 79 | func processArgs() error { 80 | args := flag.Args() 81 | results := make([]string, 0, len(args)) 82 | for _, arg := range args { 83 | result, err := processValue(arg) 84 | if err != nil { 85 | return err 86 | } 87 | results = append(results, result) 88 | } 89 | _, _ = fmt.Fprintln(os.Stdout, strings.Join(results, " ")) 90 | return nil 91 | } 92 | 93 | func processStdin() error { 94 | first := true 95 | scanner := bufio.NewScanner(os.Stdin) 96 | scanner.Split(bufio.ScanWords) 97 | for scanner.Scan() { 98 | result, err := processValue(scanner.Text()) 99 | if err != nil { 100 | return err 101 | } 102 | if first { 103 | first = false 104 | } else { 105 | _, _ = fmt.Fprint(os.Stdout, " ") 106 | } 107 | _, _ = fmt.Fprint(os.Stdout, result) 108 | } 109 | err := scanner.Err() 110 | if err != nil { 111 | return fmt.Errorf("scanner: %w", err) 112 | } 113 | return nil 114 | } 115 | 116 | func processValue(v string) (string, error) { 117 | if strings.Contains(v, ",") { 118 | return processLatLon(v) 119 | } 120 | return processGeohash(v) 121 | } 122 | 123 | func processLatLon(latLon string) (string, error) { 124 | latLonSplit := strings.Split(latLon, ",") 125 | if len(latLonSplit) != 2 { 126 | return "", fmt.Errorf("'%s'' is not a valid location (lat,lon)", latLon) 127 | } 128 | 129 | lat, err := strconv.ParseFloat(latLonSplit[0], 64) 130 | if err != nil { 131 | return "", fmt.Errorf("latitude: %w", err) 132 | } 133 | 134 | lon, err := strconv.ParseFloat(latLonSplit[1], 64) 135 | if err != nil { 136 | return "", fmt.Errorf("longitude: %w", err) 137 | } 138 | 139 | var gh string 140 | if flagPrecision > 0 { 141 | gh = geohash.Encode(lat, lon, flagPrecision) 142 | } else { 143 | gh = geohash.EncodeAuto(lat, lon) 144 | } 145 | return gh, nil 146 | } 147 | 148 | func processGeohash(arg string) (string, error) { 149 | box, err := geohash.Decode(arg) 150 | if err != nil { 151 | return "", fmt.Errorf("geohash: %w", err) 152 | } 153 | 154 | var p geohash.Point 155 | if flagRound { 156 | p = box.Round() 157 | } else { 158 | p = box.Center() 159 | } 160 | 161 | return fmt.Sprintf("%v,%v", p.Lat, p.Lon), nil 162 | } 163 | -------------------------------------------------------------------------------- /geohash.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package geohash provides an implementation of geohash. 3 | 4 | http://en.wikipedia.com/wiki/Geohash 5 | 6 | http://geohash.org 7 | */ 8 | package geohash 9 | 10 | import ( 11 | "fmt" 12 | "math" 13 | ) 14 | 15 | const ( 16 | base32 = "0123456789bcdefghjkmnpqrstuvwxyz" 17 | ) 18 | 19 | var ( 20 | base32Lookup [1 << 8]int 21 | defaultBox = Box{Lat: Range{Min: -90, Max: 90}, Lon: Range{Min: -180, Max: 180}} 22 | ) 23 | 24 | func init() { 25 | for i := range base32Lookup { 26 | base32Lookup[i] = -1 27 | } 28 | for i := range len(base32) { 29 | base32Lookup[base32[i]] = i 30 | } 31 | } 32 | 33 | // EncodeAuto encodes a location to a geohash using the most suitable precision. 34 | func EncodeAuto(lat, lon float64) string { 35 | var gh string 36 | for precision := 1; precision <= encodeMaxPrecision; precision++ { 37 | gh = Encode(lat, lon, precision) 38 | b, _ := Decode(gh) 39 | p := b.Round() 40 | if p.Lat == lat && p.Lon == lon { 41 | break 42 | } 43 | } 44 | return gh 45 | } 46 | 47 | const encodeMaxPrecision = 32 48 | 49 | // Encode encodes a location to a geohash. 50 | // 51 | // The maximum supported precision is 32. 52 | func Encode(lat, lon float64, precision int) string { 53 | if precision > encodeMaxPrecision { 54 | precision = encodeMaxPrecision 55 | } 56 | var buf [encodeMaxPrecision]byte 57 | box := defaultBox 58 | even := true 59 | for i := range precision { 60 | ci := 0 61 | for mask := 1 << 4; mask != 0; mask >>= 1 { 62 | var r *Range 63 | var u float64 64 | if even { 65 | r = &box.Lon 66 | u = lon 67 | } else { 68 | r = &box.Lat 69 | u = lat 70 | } 71 | if mid := r.Mid(); u >= mid { 72 | ci += mask 73 | r.Min = mid 74 | } else { 75 | r.Max = mid 76 | } 77 | even = !even 78 | } 79 | buf[i] = base32[ci] 80 | } 81 | return string(buf[:precision]) 82 | } 83 | 84 | // Decode decode a geohash to a [Box]. 85 | func Decode(gh string) (Box, error) { 86 | box := defaultBox 87 | even := true 88 | for i := range len(gh) { 89 | ci := base32Lookup[gh[i]] 90 | if ci == -1 { 91 | return box, fmt.Errorf("geohash decode '%s': invalid character at index %d", gh, i) 92 | } 93 | for mask := 1 << 4; mask != 0; mask >>= 1 { 94 | var r *Range 95 | if even { 96 | r = &box.Lon 97 | } else { 98 | r = &box.Lat 99 | } 100 | if mid := r.Mid(); ci&mask != 0 { 101 | r.Min = mid 102 | } else { 103 | r.Max = mid 104 | } 105 | even = !even 106 | } 107 | } 108 | return box, nil 109 | } 110 | 111 | // Box is a spatial data structure. 112 | // 113 | // It is defined by 2 ranges of latitude/longitude. 114 | type Box struct { 115 | Lat, Lon Range 116 | } 117 | 118 | // Center returns the Box's center as a Point. 119 | func (b Box) Center() Point { 120 | return Point{Lat: b.Lat.Mid(), Lon: b.Lon.Mid()} 121 | } 122 | 123 | // Round returns the Box's approximate location as a [Point]. 124 | // 125 | // It uses decimal rounding and is in general more useful than Center. 126 | func (b Box) Round() Point { 127 | return Point{Lat: b.Lat.Round(), Lon: b.Lon.Round()} 128 | } 129 | 130 | // Point represents a location (latitude and longitude). 131 | type Point struct { 132 | Lat, Lon float64 133 | } 134 | 135 | // Range represents a range (min/max) on latitude or longitude. 136 | type Range struct { 137 | Min, Max float64 138 | } 139 | 140 | // Val returns the difference between Min and Max. 141 | func (r Range) Val() float64 { 142 | return math.Abs(r.Max - r.Min) 143 | } 144 | 145 | // Mid return the middle value between Min and Max. 146 | func (r Range) Mid() float64 { 147 | return (r.Min + r.Max) / 2 148 | } 149 | 150 | // Round returns the rounded value between Min and Max. 151 | // 152 | // It uses decimal rounding. 153 | func (r Range) Round() float64 { 154 | dec := int(math.Floor(-math.Log10(r.Val()))) 155 | if dec < 0 { 156 | dec = 0 157 | } 158 | return roundDecimal(r.Mid(), dec) 159 | } 160 | 161 | // Neighbors will contain the geohashes for the neighbors of the supplied 162 | // geohash in each of the cardinal and intercardinal directions. 163 | type Neighbors struct { 164 | North string 165 | NorthEast string 166 | East string 167 | SouthEast string 168 | South string 169 | SouthWest string 170 | West string 171 | NorthWest string 172 | } 173 | 174 | // GetNeighbors returns a struct representing the [Neighbors] of the supplied 175 | // geohash in each of the cardinal and intercardinal directions. 176 | func GetNeighbors(gh string) (Neighbors, error) { 177 | box, err := Decode(gh) 178 | if err != nil { 179 | return Neighbors{}, err 180 | } 181 | latMid := box.Lat.Mid() 182 | lonMid := box.Lon.Mid() 183 | latVal := box.Lat.Val() 184 | lonVal := box.Lon.Val() 185 | precision := len(gh) 186 | encode := func(lat, lon float64) string { 187 | lat, lon = normalize(lat, lon) 188 | return Encode(lat, lon, precision) 189 | } 190 | return Neighbors{ 191 | North: encode(latMid+latVal, lonMid), 192 | NorthEast: encode(latMid+latVal, lonMid+lonVal), 193 | East: encode(latMid, lonMid+lonVal), 194 | SouthEast: encode(latMid-latVal, lonMid+lonVal), 195 | South: encode(latMid-latVal, lonMid), 196 | SouthWest: encode(latMid-latVal, lonMid-lonVal), 197 | West: encode(latMid, lonMid-lonVal), 198 | NorthWest: encode(latMid+latVal, lonMid-lonVal), 199 | }, nil 200 | } 201 | -------------------------------------------------------------------------------- /geohash_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package geohash 2 | 3 | import ( 4 | "testing" 5 | 6 | codefor_geohash "github.com/Codefor/geohash" 7 | tomi_hiltunen_geohash "github.com/TomiHiltunen/geohash-golang" 8 | broadygeohash "github.com/broady/gogeohash" //nolint:misspell 9 | fanixk_geohash "github.com/fanixk/geohash" 10 | mmcloughlin_geohash "github.com/mmcloughlin/geohash" 11 | "github.com/pierrre/assert" 12 | the42_cartconvert_geohash "github.com/the42/cartconvert/cartconvert" 13 | ) 14 | 15 | func BenchmarkEncode(b *testing.B) { 16 | for b.Loop() { 17 | Encode(testPoint.Lat, testPoint.Lon, testPrecision) 18 | } 19 | } 20 | 21 | func BenchmarkDecode(b *testing.B) { 22 | for b.Loop() { 23 | _, err := Decode(testGeohash) 24 | if err != nil { 25 | assert.NoError(b, err) 26 | } 27 | } 28 | } 29 | 30 | func BenchmarkNeighbors(b *testing.B) { 31 | for b.Loop() { 32 | _, err := GetNeighbors(testGeohash) 33 | if err != nil { 34 | assert.NoError(b, err) 35 | } 36 | } 37 | } 38 | 39 | func BenchmarkCodeforEncode(b *testing.B) { 40 | for b.Loop() { 41 | codefor_geohash.Encode(testPoint.Lat, testPoint.Lon) 42 | } 43 | } 44 | 45 | func BenchmarkCodeforDecode(b *testing.B) { 46 | for b.Loop() { 47 | codefor_geohash.Decode(testGeohash) 48 | } 49 | } 50 | 51 | func BenchmarkTomiHiltunenEncode(b *testing.B) { 52 | for b.Loop() { 53 | tomi_hiltunen_geohash.EncodeWithPrecision(testPoint.Lat, testPoint.Lon, testPrecision) 54 | } 55 | } 56 | 57 | func BenchmarkTomiHiltunenDecode(b *testing.B) { 58 | for b.Loop() { 59 | tomi_hiltunen_geohash.Decode(testGeohash) 60 | } 61 | } 62 | 63 | func BenchmarkTomiHiltunenNeighbors(b *testing.B) { 64 | for b.Loop() { 65 | tomi_hiltunen_geohash.CalculateAllAdjacent(testGeohash) 66 | } 67 | } 68 | 69 | func BenchmarkBroadyEncode(b *testing.B) { 70 | for b.Loop() { 71 | broadygeohash.Encode(testPoint.Lat, testPoint.Lon) 72 | } 73 | } 74 | 75 | func BenchmarkBroadyDecode(b *testing.B) { 76 | for b.Loop() { 77 | broadygeohash.Decode(testGeohash) 78 | } 79 | } 80 | 81 | func BenchmarkFanixkEncode(b *testing.B) { 82 | for b.Loop() { 83 | fanixk_geohash.PrecisionEncode(testPoint.Lat, testPoint.Lon, testPrecision) 84 | } 85 | } 86 | 87 | func BenchmarkFanixkDecode(b *testing.B) { 88 | for b.Loop() { 89 | fanixk_geohash.DecodeBoundingBox(testGeohash) 90 | } 91 | } 92 | 93 | func BenchmarkFanixkNeighbors(b *testing.B) { 94 | for b.Loop() { 95 | fanixk_geohash.Neighbors(testGeohash) 96 | } 97 | } 98 | 99 | func BenchmarkMmcloughlinEncode(b *testing.B) { 100 | for b.Loop() { 101 | mmcloughlin_geohash.EncodeWithPrecision(testPoint.Lat, testPoint.Lon, testPrecision) 102 | } 103 | } 104 | 105 | func BenchmarkMmcloughlinDecode(b *testing.B) { 106 | for b.Loop() { 107 | mmcloughlin_geohash.BoundingBox(testGeohash) 108 | } 109 | } 110 | 111 | func BenchmarkMmcloughlinNeighbors(b *testing.B) { 112 | for b.Loop() { 113 | fanixk_geohash.Neighbors(testGeohash) 114 | } 115 | } 116 | 117 | func BenchmarkThe42CartconvertEncode(b *testing.B) { 118 | pc := &the42_cartconvert_geohash.PolarCoord{ 119 | Latitude: testPoint.Lat, 120 | Longitude: testPoint.Lon, 121 | Height: 0, 122 | El: the42_cartconvert_geohash.DefaultEllipsoid, 123 | } 124 | precision := byte(testPrecision) 125 | for b.Loop() { 126 | the42_cartconvert_geohash.LatLongToGeoHashBits(pc, precision) 127 | } 128 | } 129 | 130 | func BenchmarkThe42CartconvertDecode(b *testing.B) { 131 | for b.Loop() { 132 | _, err := the42_cartconvert_geohash.GeoHashToLatLong(testGeohash, the42_cartconvert_geohash.DefaultEllipsoid) 133 | if err != nil { 134 | assert.NoError(b, err) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /geohash_test.go: -------------------------------------------------------------------------------- 1 | package geohash 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/pierrre/assert" 7 | ) 8 | 9 | const ( 10 | testGeohash = "u09tvqxnnuph" 11 | testPrecision = 12 12 | ) 13 | 14 | var testPoint = Point{Lat: 48.86, Lon: 2.35} 15 | 16 | func TestEncodeAuto(t *testing.T) { 17 | gh := EncodeAuto(testPoint.Lat, testPoint.Lon) 18 | assert.Equal(t, gh, testGeohash[:7]) 19 | } 20 | 21 | func TestEncode(t *testing.T) { 22 | gh := Encode(testPoint.Lat, testPoint.Lon, testPrecision) 23 | assert.Equal(t, gh, testGeohash) 24 | } 25 | 26 | func TestEncodeMaxPrecision(t *testing.T) { 27 | gh := Encode(testPoint.Lat, testPoint.Lon, encodeMaxPrecision+1) 28 | assert.StringLen(t, gh, encodeMaxPrecision) 29 | } 30 | 31 | func TestDecode(t *testing.T) { 32 | box, err := Decode(testGeohash) 33 | assert.NoError(t, err) 34 | assert.True(t, testPointIsInsideBox(testPoint, box)) 35 | } 36 | 37 | func TestDecodeInvalidCharacter(t *testing.T) { 38 | _, err := Decode("é") 39 | assert.Error(t, err) 40 | } 41 | 42 | func TestBoxCenter(t *testing.T) { 43 | box := Box{ 44 | Lat: Range{ 45 | Min: testPoint.Lat - 1, 46 | Max: testPoint.Lat + 1, 47 | }, 48 | Lon: Range{ 49 | Min: testPoint.Lon - 1, 50 | Max: testPoint.Lon + 1, 51 | }, 52 | } 53 | center := box.Center() 54 | assert.Equal(t, center, testPoint) 55 | } 56 | 57 | func TestBoxRound(t *testing.T) { 58 | box, err := Decode(testGeohash) 59 | assert.NoError(t, err) 60 | round := box.Round() 61 | assert.Equal(t, round, testPoint) 62 | assert.NotEqual(t, round, box.Center()) 63 | } 64 | 65 | func testPointIsInsideBox(p Point, b Box) bool { 66 | return testValueIsInsideRange(p.Lat, b.Lat) && 67 | testValueIsInsideRange(p.Lon, b.Lon) 68 | } 69 | 70 | func testValueIsInsideRange(v float64, r Range) bool { 71 | return v >= r.Min && v <= r.Max 72 | } 73 | 74 | func TestNeighbors(t *testing.T) { 75 | for _, tc := range []struct { 76 | gh string 77 | expected Neighbors 78 | }{ 79 | { 80 | gh: testGeohash, 81 | expected: Neighbors{ 82 | North: "u09tvqxnnupj", 83 | NorthEast: "u09tvqxnnupm", 84 | East: "u09tvqxnnupk", 85 | SouthEast: "u09tvqxnnup7", 86 | South: "u09tvqxnnup5", 87 | SouthWest: "u09tvqxnnung", 88 | West: "u09tvqxnnunu", 89 | NorthWest: "u09tvqxnnunv", 90 | }, 91 | }, 92 | { 93 | gh: Encode(0, 0, 4), // s000 94 | expected: Neighbors{ 95 | North: "s001", 96 | NorthEast: "s003", 97 | East: "s002", 98 | SouthEast: "kpbr", 99 | South: "kpbp", 100 | SouthWest: "7zzz", 101 | West: "ebpb", 102 | NorthWest: "ebpc", 103 | }, 104 | }, 105 | { 106 | gh: Encode(0, 180, 4), // xbpb 107 | expected: Neighbors{ 108 | North: "xbpc", 109 | NorthEast: "8001", 110 | East: "8000", 111 | SouthEast: "2pbp", 112 | South: "rzzz", 113 | SouthWest: "rzzx", 114 | West: "xbp8", 115 | NorthWest: "xbp9", 116 | }, 117 | }, 118 | { 119 | gh: Encode(90, 0, 4), // upbp 120 | expected: Neighbors{ 121 | North: "bpbp", 122 | NorthEast: "bpbr", 123 | East: "upbr", 124 | SouthEast: "upbq", 125 | South: "upbn", 126 | SouthWest: "gzzy", 127 | West: "gzzz", 128 | NorthWest: "zzzz", 129 | }, 130 | }, 131 | } { 132 | t.Run(tc.gh, func(t *testing.T) { 133 | neighbors, err := GetNeighbors(tc.gh) 134 | assert.NoError(t, err) 135 | assert.Equal(t, neighbors, tc.expected) 136 | }) 137 | } 138 | } 139 | 140 | func TestNeighborsInvalidCharacter(t *testing.T) { 141 | _, err := GetNeighbors("é") 142 | assert.Error(t, err) 143 | } 144 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pierrre/geohash 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d 9 | github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb 10 | github.com/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042 11 | github.com/fanixk/geohash v0.0.0-20150324002647-c1f9b5fa157a 12 | github.com/mmcloughlin/geohash v0.10.0 13 | github.com/pierrre/assert v0.8.5 14 | github.com/the42/cartconvert v1.0.0 15 | ) 16 | 17 | require ( 18 | github.com/pierrre/compare v1.4.13 // indirect 19 | github.com/pierrre/go-libs v0.16.4 // indirect 20 | github.com/pierrre/pretty v0.14.3 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d h1:iG9B49Q218F/XxXNRM7k/vWf7MKmLIS8AcJV9cGN4nA= 2 | github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d/go.mod h1:RVnhzAX71far8Kc3TQeA0k/dcaEKUnTDSOyet/JCmGI= 3 | github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb h1:wumPkzt4zaxO4rHPBrjDK8iZMR41C1qs7njNqlacwQg= 4 | github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb/go.mod h1:QiYsIBRQEO+Z4Rz7GoI+dsHVneZNONvhczuA+llOZNM= 5 | github.com/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042 h1:iEdmkrNMLXbM7ecffOAtZJQOQUTE4iMonxrb5opUgE4= 6 | github.com/broady/gogeohash v0.0.0-20120525094510-7b2c40d64042/go.mod h1:f1L9YvXvlt9JTa+A17trQjSMM6bV40f+tHjB+Pi+Fqk= 7 | github.com/fanixk/geohash v0.0.0-20150324002647-c1f9b5fa157a h1:Fyfh/dsHFrC6nkX7H7+nFdTd1wROlX/FxEIWVpKYf1U= 8 | github.com/fanixk/geohash v0.0.0-20150324002647-c1f9b5fa157a/go.mod h1:UgNw+PTmmGN8rV7RvjvnBMsoTU8ZXXnaT3hYsDTBlgQ= 9 | github.com/mmcloughlin/geohash v0.10.0 h1:9w1HchfDfdeLc+jFEf/04D27KP7E2QmpDu52wPbJWRE= 10 | github.com/mmcloughlin/geohash v0.10.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c= 11 | github.com/pierrre/assert v0.8.5 h1:vbJ0Etf4zi6WMB9vAXWz8WGoCvXyjL/qP9KrW0xejaA= 12 | github.com/pierrre/assert v0.8.5/go.mod h1:vTsWTa1asemxrnQf9sh5Rs7GULcNHYDkjknVL0sHq/g= 13 | github.com/pierrre/compare v1.4.13 h1:b6gi3OgN1emmD1Ly37m+B/Pbq6tac+w3lNGT5xu4I10= 14 | github.com/pierrre/compare v1.4.13/go.mod h1:+ie0ecM2nS32oLck0FWDstwIUSZ0YF4KBIaACOvKhJM= 15 | github.com/pierrre/go-libs v0.16.4 h1:SC6QtJnh/yCtrGItoMSCjzt/MmlvQrmRNYT/6nz0HqY= 16 | github.com/pierrre/go-libs v0.16.4/go.mod h1:EiBCyr6jBELeV4G7wbm8VcAHyUxK3sbFvDCObE/rv60= 17 | github.com/pierrre/pretty v0.14.3 h1:I100hHs1C/MCd3M0D/hIV7J2OXl7amLD0uP2jnB7mRw= 18 | github.com/pierrre/pretty v0.14.3/go.mod h1:HTaFDNtT9ELVK5pODLfXRLiEiyIx3MmQUL5UadrR3/0= 19 | github.com/the42/cartconvert v1.0.0 h1:g8kt6ic2GEhdcZ61ZP9GsWwhosVo5nCnH1n2/oAQXUU= 20 | github.com/the42/cartconvert v1.0.0/go.mod h1:fWO/msnJVhHqN1yX6OBoxSyfj7TEj1hHiL8bJSQsK30= 21 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package geohash 2 | 3 | import ( 4 | "math" 5 | ) 6 | 7 | func round(val float64) float64 { 8 | if val < 0 { 9 | return math.Ceil(val - 0.5) 10 | } 11 | return math.Floor(val + 0.5) 12 | } 13 | 14 | func roundDecimal(val float64, dec int) float64 { 15 | factor := math.Pow10(dec) 16 | return round(val*factor) / factor 17 | } 18 | 19 | func normalize(lat, lon float64) (latOut, lonOut float64) { 20 | if lat > 90 || lat < -90 { 21 | lat = center360(lat) 22 | invertLon := true 23 | switch { 24 | case lat < -90: 25 | lat = -180 - lat 26 | case lat > 90: 27 | lat = 180 - lat 28 | default: 29 | invertLon = false 30 | } 31 | if invertLon { 32 | if lon > 0 { 33 | lon -= 180 34 | } else { 35 | lon += 180 36 | } 37 | } 38 | } 39 | if lon > 180 || lon <= -180 { 40 | lon = center360(lon) 41 | } 42 | return lat, lon 43 | } 44 | 45 | func center360(v float64) float64 { 46 | v = math.Mod(v, 360) 47 | if v <= 0 { 48 | v += 360 49 | } 50 | if v > 180 { 51 | v -= 360 52 | } 53 | return v 54 | } 55 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package geohash 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/pierrre/assert" 7 | ) 8 | 9 | func TestRound(t *testing.T) { 10 | for _, tc := range []struct { 11 | value float64 12 | expected float64 13 | }{ 14 | {-1.7, -2}, 15 | {-1.5, -2}, 16 | {-1.3, -1}, 17 | {-1, -1}, 18 | {0, 0}, 19 | {1, 1}, 20 | {1.3, 1}, 21 | {1.5, 2}, 22 | {1.7, 2}, 23 | } { 24 | result := round(tc.value) 25 | assert.Equal(t, result, tc.expected) 26 | } 27 | } 28 | 29 | func TestRoundDecimal(t *testing.T) { 30 | value := 12.345678 31 | for _, tc := range []struct { 32 | dec int 33 | expected float64 34 | }{ 35 | {0, 12}, 36 | {1, 12.3}, 37 | {2, 12.35}, 38 | {3, 12.346}, 39 | {4, 12.3457}, 40 | {5, 12.34568}, 41 | {6, 12.345678}, 42 | } { 43 | result := roundDecimal(value, tc.dec) 44 | assert.Equal(t, result, tc.expected) 45 | } 46 | } 47 | 48 | func TestNormalize(t *testing.T) { 49 | for _, tc := range []struct { 50 | lat, lon float64 51 | expectedLat, expectedLon float64 52 | }{ 53 | {testPoint.Lat, testPoint.Lon, testPoint.Lat, testPoint.Lon}, 54 | {0, 0, 0, 0}, 55 | {45, 90, 45, 90}, 56 | {-45, -90, -45, -90}, 57 | {90, 0, 90, 0}, 58 | {-90, 0, -90, 0}, 59 | {0, 180, 0, 180}, 60 | {1, -180, 1, 180}, 61 | {91, 0, 89, 180}, 62 | {91, 1, 89, -179}, 63 | {-91, -1, -89, 179}, 64 | {0, 181, 0, -179}, 65 | {0, -181, 0, 179}, 66 | {270, 1, -90, 1}, 67 | } { 68 | resultLat, resultLon := normalize(tc.lat, tc.lon) 69 | assert.Equal(t, resultLat, tc.expectedLat) 70 | assert.Equal(t, resultLon, tc.expectedLon) 71 | } 72 | } 73 | 74 | func TestCenter360(t *testing.T) { 75 | for _, tc := range []struct { 76 | value float64 77 | expected float64 78 | }{ 79 | {0, 0}, 80 | {45, 45}, 81 | {180, 180}, 82 | {181, -179}, 83 | {-181, 179}, 84 | {-180, 180}, 85 | {-45, -45}, 86 | } { 87 | result := center360(tc.value) 88 | assert.Equal(t, result, tc.expected) 89 | } 90 | } 91 | --------------------------------------------------------------------------------