honua 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +22 -0
- data/README.md +184 -0
- data/lib/honua.rb +19 -0
- data/lib/honua/configuration.rb +28 -0
- data/lib/honua/coordinate.rb +66 -0
- data/lib/honua/helpers.rb +92 -0
- data/lib/honua/identity.rb +10 -0
- data/lib/honua/location.rb +23 -0
- data/lib/honua/map.rb +136 -0
- data/lib/honua/tile.rb +91 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9dcc6198120b25c09baeb8619f31cf9069d60c7b0dd7926e45aaff7144273302
|
4
|
+
data.tar.gz: c7c86a44e9e0e26384adc9b77a79f01904dc88684f076d4abd58916603fe70b9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 942643724880d18c28337d3ea140f9c2baebfd787e1b411f2de2e8e550d1d29a4c590b090a8da6901b4f007e2539a02f612c55d855fac30fdfd4071595989a46
|
7
|
+
data.tar.gz: 41fdf1137a3e5208fe0529a8c9b8b350fc47f9574a5b90644b0dee015a70e2a4973e8915046a06619ec19954b1e7bdc493b1783f69a5210c6f02e6019a83f0f6
|
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2021 dingsdax
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,184 @@
|
|
1
|
+
# Honua
|
2
|
+
|
3
|
+
![](docs/header.png)
|
4
|
+
|
5
|
+
> Honua is Hawaiian for earth 🌍
|
6
|
+
|
7
|
+
Honua is a simple geographic mapping Library for Ruby. It allows you to stitch geographic map images based on map tiles, provided by a rastered tile server (in Mercator projection). It was highly inspired by [ModestMaps](https://github.com/modestmaps/).
|
8
|
+
|
9
|
+
## Requirements
|
10
|
+
|
11
|
+
1. [Ruby 3.0](https://www.ruby-lang.org)
|
12
|
+
2. [libvips](https://libvips.github.io/libvips/)
|
13
|
+
|
14
|
+
## Setup
|
15
|
+
|
16
|
+
To install, run:
|
17
|
+
``` ruby
|
18
|
+
gem install honua
|
19
|
+
```
|
20
|
+
|
21
|
+
Add the following to your Gemfile:
|
22
|
+
|
23
|
+
``` ruby
|
24
|
+
gem "honua"
|
25
|
+
```
|
26
|
+
|
27
|
+
## Usage
|
28
|
+
|
29
|
+
### Coordinate Systems
|
30
|
+
|
31
|
+
Like almost every popular mapping library it uses the [Web Mercator Projection](https://en.wikipedia.org/wiki/Web_Mercator_projection) as basis for conversions and its tile coordinate system.
|
32
|
+
|
33
|
+
* A [Location](lib/honua/location.rb) is a geographic place described in latitude and longitude coordinates in degrees.
|
34
|
+
* A [Coordinate](lib/honua/coordinate.rb) is a geographic place described in a tile coordinate system with columns (x), rows (y) and a zoom level.
|
35
|
+
|
36
|
+
To convert from `Location` to `Coordinate` at a certain zoom level:
|
37
|
+
|
38
|
+
1. convert latitude and longitude to radians
|
39
|
+
2. [reproject coordinates](https://epsg.io/transform#s_srs=4326&t_srs=3857) to Web Mercator projections
|
40
|
+
3. transform range of `column` and `row` to 0 - 1 and shift origin to top left corner
|
41
|
+
4. multiply `row` and `column` with number of tiles (2**zoom) and round results
|
42
|
+
|
43
|
+
### Configuration
|
44
|
+
|
45
|
+
At minimal you need to provide the url for a [tile server](https://github.com/Overv/openstreetmap-tile-server) and the appropriate attribution text for the used tiles. If the map is too small to show the attribution it will be skipped and you need to provide it separetly. Don't forget to check out terms and service for the tile server you are planning to use.
|
46
|
+
|
47
|
+
``` ruby
|
48
|
+
Honua.configure do |config|
|
49
|
+
# each zoom level is a directory, each column is a subdirectory, and each tile in that column is a file
|
50
|
+
config.tiles_url = 'http://localhost:8080/%<zoom>s/%<column>s/%<row>s.png'
|
51
|
+
config.attribution_text = '<b>© OpenStreetMap contributors</b>'
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
Checkout [configuration.rb](lib/honua/configuration.rb) for further details.
|
56
|
+
|
57
|
+
A map is defined by a center location, map dimensions (width and height in pixels) and a [zoom level](https://wiki.openstreetmap.org/wiki/Zoom_levels). The `draw` method downloads all necessary tiles for the map, stitches them together, adds the proper attribution at the bottom and finally returns a [`Vips::Image`](https://www.rubydoc.info/gems/ruby-vips/Vips/Image).
|
58
|
+
|
59
|
+
``` ruby
|
60
|
+
map_center = Honua::Location.new(35.689487, 139.691711) # Tokyo
|
61
|
+
map = Honua::Map.new(center: map_center, width: 600, height: 600, zoom: 12)
|
62
|
+
map_image = map.draw
|
63
|
+
map_image.write_to_file('tokyo.png') # use Vips to write to file
|
64
|
+
```
|
65
|
+
|
66
|
+
### Helpers
|
67
|
+
|
68
|
+
There might be use cases, where you have a bunch of location instead of map center, or you either have map dimensions or a zoom level but not both. Honua includes helpers that can be used to calculate the needed map input parameters:
|
69
|
+
|
70
|
+
* [`Honua::Helpers.map_span(locations:)`](lib/honua/helpers.rb) returns the top left and bottom right coordinates at zoom level 0
|
71
|
+
* [`Honua::Helpers.map_span(top_left:, bottom_right:)`](lib/honua/helpers.rb) returns the center coordinate based on map spanning top left and bottom right coordinates
|
72
|
+
* [`Honua::Helpers.calculate_zoom(top_left:, bottom_right:, width:, height:)`](lib/honua/helpers.rb) returns zoom level based on map spanning coordinates and map dimensions
|
73
|
+
* [`Honua::Helpers.calculate_map_dimensions(top_left:, bottom_right:, zoom:)`](lib/honua/helpers.rb) returns map dimensions (in pixels) based on map spanning coordinates and a zoom value
|
74
|
+
* [`Honua::Helpers.text_label((text:, dpi:, text_colour:, shadow_colour:))`](lib/honua/helpers.rb) generates a text label image with shadow and shadow offset, which can be places on the map.
|
75
|
+
* [`Honua::Helpers.hex2rgb(hex)`](lib/honua/helpers.rb) converts hex color strings to an array of RGB
|
76
|
+
|
77
|
+
Please refer to the [examples](docs) for how these helpers might be used.
|
78
|
+
|
79
|
+
[🏙 World & Cities](docs/world_calc_zoom.rb)
|
80
|
+
|
81
|
+
![](docs/the_world.png)
|
82
|
+
|
83
|
+
|
84
|
+
[🌉 From SF to LA](docs/from_sf_to_la.rb)
|
85
|
+
|
86
|
+
![](docs/california.png)
|
87
|
+
|
88
|
+
|
89
|
+
[🇨🇦 Maps of Vancouer](docs/vancouver_maps.rb)
|
90
|
+
|
91
|
+
![](docs/vancouver_500x500z11.png)
|
92
|
+
|
93
|
+
## Development
|
94
|
+
|
95
|
+
To contribute, run:
|
96
|
+
|
97
|
+
git clone https://github.com//honua.git
|
98
|
+
cd honua
|
99
|
+
bundle install
|
100
|
+
|
101
|
+
You can also use the IRB console for direct access to all objects:
|
102
|
+
``` shell
|
103
|
+
bin/console
|
104
|
+
```
|
105
|
+
|
106
|
+
### Tile Server
|
107
|
+
|
108
|
+
If you want to use the included [tile server](https://github.com/Overv/openstreetmap-tile-server) be sure to install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/).
|
109
|
+
|
110
|
+
After you downloaded an `.osm.pbf` extract and `.poly` file from e.g [Geofabrik](https://download.geofabrik.de/) for the region that you're interested in, replace the placeholders in `docker-compose.yml` and run the importer with:
|
111
|
+
``` shell
|
112
|
+
docker compose up osmimport
|
113
|
+
|
114
|
+
```
|
115
|
+
|
116
|
+
To start the tile server, run:
|
117
|
+
``` shell
|
118
|
+
docker compose up osmtileserver
|
119
|
+
|
120
|
+
```
|
121
|
+
|
122
|
+
You can now go to `localhost:8080` for a fullscreen [Leaflet](https://leafletjs.com/) based map. To use the tile server in Honua, add the following configuration in your code:
|
123
|
+
``` ruby
|
124
|
+
Honua.configure do |config|
|
125
|
+
config.tiles_url = 'http://localhost:8080/tile/%<zoom>s/%<column>s/%<row>s.png'
|
126
|
+
config.attribution_text = '<b>© OpenStreetMap contributors</b>' # don't forget to attribute 👍
|
127
|
+
end
|
128
|
+
```
|
129
|
+
|
130
|
+
|
131
|
+
😏 If you want to merge 2 or more extracts you can use [osmctools](https://gitlab.com/osm-c-tools/osmctools) or [osmctools-docker](https://github.com/tobilg/osmctools-docker).
|
132
|
+
|
133
|
+
## Tests
|
134
|
+
|
135
|
+
To test, run:
|
136
|
+
``` shell
|
137
|
+
bundle exec rake
|
138
|
+
```
|
139
|
+
|
140
|
+
## Versioning
|
141
|
+
|
142
|
+
Read [Semantic Versioning](https://semver.org) for details. Briefly, it means:
|
143
|
+
|
144
|
+
- Major (X.y.z) - Incremented for any backwards incompatible public API changes.
|
145
|
+
- Minor (x.Y.z) - Incremented for new, backwards compatible, public API enhancements/fixes.
|
146
|
+
- Patch (x.y.Z) - Incremented for small, backwards compatible, bug fixes.
|
147
|
+
|
148
|
+
## Contributions
|
149
|
+
|
150
|
+
Read [CONTRIBUTING](CONTRIBUTING.md) for details.
|
151
|
+
|
152
|
+
## License
|
153
|
+
|
154
|
+
Copyright 2021 by dingsdax.
|
155
|
+
Read [LICENSE](LICENSE) for details.
|
156
|
+
|
157
|
+
## History
|
158
|
+
|
159
|
+
Read [CHANGES](CHANGES.md) for details.
|
160
|
+
Built with [Gemsmith](https://www.alchemists.io/projects/gemsmith).
|
161
|
+
|
162
|
+
## Further Reading & Credits
|
163
|
+
|
164
|
+
Some of the resources that helped building and understanding the underlying logic behind this gem.
|
165
|
+
This started out as a Ruby port of [modest-maps.js](https://github.com/modestmaps/modestmaps-js) but took on a life of its own. [ModestMaps](https://github.com/modestmaps/) and [Stamen](https://stamen.com/) are awesome! Big shoutout for their work and inspiration 🙇♂️.
|
166
|
+
|
167
|
+
* [Bing Maps Tile System](https://docs.microsoft.com/en-us/bingmaps/articles/bing-maps-tile-system)
|
168
|
+
* [OpenLayers FAQ](https://openlayers.org/en/latest/doc/faq.html)
|
169
|
+
* [OSM Slippy map tilenames](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames)
|
170
|
+
* [ModestMaps Wiki](https://github.com/modestmaps/modestmaps-js/wiki)
|
171
|
+
* [Mercator Projection](https://en.wikipedia.org/wiki/Mercator_projection)
|
172
|
+
* [Tiles à la Google Maps](https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection)
|
173
|
+
* [Coordinate Systems Worldwide](https://epsg.io)
|
174
|
+
|
175
|
+
## Attributions
|
176
|
+
|
177
|
+
<div>Logo & marker icons made by <a href="https://www.freepik.com" title="Freepik">Freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a></div>
|
178
|
+
|
179
|
+
Toner & Terrain map tiles by Stamen Design (http://stamen.com),
|
180
|
+
under CC BY 3.0 (http://creativecommons.org/licenses/by/3.0).
|
181
|
+
Data by OpenStreetMap (http://openstreetmap.org), under ODbL (http://www.openstreetmap.org/copyright).
|
182
|
+
|
183
|
+
[Explorer Base Map header image](https://visibleearth.nasa.gov/images/147190/explorer-base-map/147193)
|
184
|
+
NASA Earth Observatory map by Joshua Stevens using data from NASA’s MODIS Land Cover, the Shuttle Radar Topography Mission (SRTM), the General Bathymetric Chart of the Oceans (GEBCO), and Natural Earth boundaries.
|
data/lib/honua.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'zeitwerk'
|
4
|
+
loader = Zeitwerk::Loader.for_gem
|
5
|
+
loader.setup
|
6
|
+
|
7
|
+
module Honua
|
8
|
+
Point = Struct.new(:x, :y)
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def configuration
|
12
|
+
@configuration ||= Configuration.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def configure
|
16
|
+
yield configuration
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Honua
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :attribution_bgcolor, :attribution_fgcolor, :attribution_text,
|
6
|
+
:max_fetch_attempts, :tile_height, :tile_width,
|
7
|
+
:tiles_url, :user_agent
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
# max attempts to fetch a tile until given up and returning an empty tile
|
11
|
+
@max_fetch_attempts = 3
|
12
|
+
|
13
|
+
# OSM map tiles are typically 256x256
|
14
|
+
# https://wiki.openstreetmap.org/wiki/Tiles
|
15
|
+
@tile_height = 256
|
16
|
+
@tile_width = 256
|
17
|
+
|
18
|
+
# user agent that's used to make requests to the tile server
|
19
|
+
@user_agent = Honua::Identity::VERSION_LABEL
|
20
|
+
|
21
|
+
# attribution_text can contain some Pango markup
|
22
|
+
# https://developer.gnome.org/pango/stable/pango-Markup.html
|
23
|
+
@attribution_text = nil
|
24
|
+
@attribution_fgcolor = '#fff'
|
25
|
+
@attribution_bgcolor = '#000'
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# a coordinate is used to indentify a tile based on the zoom level and the tile grid's x/y coordinates
|
4
|
+
module Honua
|
5
|
+
class Coordinate
|
6
|
+
attr_accessor :row, :column, :zoom
|
7
|
+
|
8
|
+
def initialize(row, column, zoom = 0)
|
9
|
+
@row = row
|
10
|
+
@column = column
|
11
|
+
@zoom = zoom
|
12
|
+
end
|
13
|
+
|
14
|
+
def zoom_to(zoom_level)
|
15
|
+
Coordinate.new(
|
16
|
+
row * (2**(zoom_level - zoom)),
|
17
|
+
column * (2**(zoom_level - zoom)),
|
18
|
+
zoom_level
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
# the top left most coordinate within the same tile
|
23
|
+
# used to identify the tile within the the tile rid
|
24
|
+
def container
|
25
|
+
Coordinate.new(row.to_i, column.to_i, zoom)
|
26
|
+
end
|
27
|
+
|
28
|
+
def up(distance = 1)
|
29
|
+
Coordinate.new(row - distance, column, zoom)
|
30
|
+
end
|
31
|
+
|
32
|
+
def right(distance = 1)
|
33
|
+
Coordinate.new(row, column + distance, zoom)
|
34
|
+
end
|
35
|
+
|
36
|
+
def down(distance = 1)
|
37
|
+
Coordinate.new(row + distance, column, zoom)
|
38
|
+
end
|
39
|
+
|
40
|
+
def left(distance = 1)
|
41
|
+
Coordinate.new(row, column - distance, zoom)
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_location
|
45
|
+
n = 2.0**zoom
|
46
|
+
lon = column / n * 360.0 - 180
|
47
|
+
lat_rad = Math.atan(Math.sinh(Math::PI * (1 - 2 * row / n)))
|
48
|
+
lat = rad2deg(lat_rad)
|
49
|
+
|
50
|
+
Location.new(lat, lon)
|
51
|
+
end
|
52
|
+
|
53
|
+
def ==(other)
|
54
|
+
row == other.row &&
|
55
|
+
column == other.column &&
|
56
|
+
zoom == other.zoom
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
# radians to degrees
|
62
|
+
def rad2deg(radians)
|
63
|
+
radians * (180 / Math::PI)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Honua
|
4
|
+
class Helpers
|
5
|
+
class << self
|
6
|
+
# returns the top left and bottom right coordinates at zoom level 0
|
7
|
+
# these corners are used to define the map span
|
8
|
+
def map_span(locations:)
|
9
|
+
coordinates = locations.map(&:to_coordinate)
|
10
|
+
|
11
|
+
top_left = Coordinate.new(
|
12
|
+
coordinates.min_by(&:row).row,
|
13
|
+
coordinates.min_by(&:column).column,
|
14
|
+
coordinates.min_by(&:zoom).zoom
|
15
|
+
)
|
16
|
+
|
17
|
+
bottom_right = Coordinate.new(
|
18
|
+
coordinates.max_by(&:row).row,
|
19
|
+
coordinates.max_by(&:column).column,
|
20
|
+
coordinates.max_by(&:zoom).zoom
|
21
|
+
)
|
22
|
+
|
23
|
+
[top_left, bottom_right]
|
24
|
+
end
|
25
|
+
|
26
|
+
# returns the center coordinate based on map spanning top left and bottom right coordinates
|
27
|
+
def map_center_coordinate(top_left:, bottom_right:)
|
28
|
+
row = (top_left.row + bottom_right.row) / 2.0
|
29
|
+
column = (top_left.column + bottom_right.column) / 2.0
|
30
|
+
zoom = (top_left.zoom + bottom_right.zoom) / 2.0
|
31
|
+
|
32
|
+
Coordinate.new(row, column, zoom)
|
33
|
+
end
|
34
|
+
|
35
|
+
# returns zoom level based on map spanning coordinates and map dimensions
|
36
|
+
# shameless copy from modestmaps.js
|
37
|
+
def calculate_zoom(top_left:, bottom_right:, width:, height:)
|
38
|
+
# multiplication factor between horizontal span and map width
|
39
|
+
h_factor = (bottom_right.column - top_left.column) / (width.to_f / Honua.configuration.tile_width)
|
40
|
+
|
41
|
+
# possible horizontal zoom to fit geographical extent in map width
|
42
|
+
h_possible_zoom = top_left.zoom - (Math.log(h_factor) / Math.log(2)).ceil
|
43
|
+
|
44
|
+
# multiplication factor between vertical span and map height
|
45
|
+
v_factor = (bottom_right.row - top_left.row) / (height.to_f / Honua.configuration.tile_height)
|
46
|
+
|
47
|
+
# possible vertical zoom to fit geographical extent in map height
|
48
|
+
v_possible_zoom = top_left.zoom - (Math.log(v_factor) / Math.log(2)).ceil
|
49
|
+
|
50
|
+
# initial zoom to fit extent vertically and horizontally
|
51
|
+
[h_possible_zoom, v_possible_zoom].min
|
52
|
+
end
|
53
|
+
|
54
|
+
# returns map dimensions (in pixels) based on map spanning coordinates and a zoom value
|
55
|
+
def calculate_map_dimensions(top_left:, bottom_right:, zoom:)
|
56
|
+
top_left = top_left.zoom_to(zoom)
|
57
|
+
bottom_right = bottom_right.zoom_to(zoom)
|
58
|
+
|
59
|
+
# map width and height in pixels
|
60
|
+
width = ((bottom_right.column - top_left.column) * Honua.configuration.tile_width).to_i
|
61
|
+
height = ((bottom_right.row - top_left.row) * Honua.configuration.tile_height).to_i
|
62
|
+
|
63
|
+
[width, height]
|
64
|
+
end
|
65
|
+
|
66
|
+
# text can contain some Pango markup
|
67
|
+
# https://developer.gnome.org/pango/stable/pango-Markup.html
|
68
|
+
def text_label(text:, dpi: 100, text_colour: [0, 0, 0, 255], shadow_colour: [255, 255, 255, 150], blur: 1.5)
|
69
|
+
text_mask, = Vips::Image.text(text, dpi: dpi)
|
70
|
+
|
71
|
+
canvas_width = text_mask.width + 10
|
72
|
+
canvas_height = text_mask.height + 10
|
73
|
+
text_mask = text_mask.gravity('west', canvas_width, canvas_height)
|
74
|
+
# use 0 - 1 for the masks
|
75
|
+
text_mask /= 255
|
76
|
+
|
77
|
+
shadow_mask = text_mask.gaussblur(blur)
|
78
|
+
# credit: https://github.com/libvips/pyvips/issues/204
|
79
|
+
text_mask = text_mask * text_colour + ((text_mask * -1) + 1) * shadow_mask * shadow_colour
|
80
|
+
|
81
|
+
text_mask.unpremultiply.copy(interpretation: 'srgb')
|
82
|
+
end
|
83
|
+
|
84
|
+
# convert hex color string to something VIPS understands
|
85
|
+
def hex2rgb(hex)
|
86
|
+
color_array = (hex.match(/#(..?)(..?)(..?)/))[1..3]
|
87
|
+
color_array.map! { |x| x + x } if hex.size == 4 # e.g. #333
|
88
|
+
color_array.map(&:hex)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Honua
|
4
|
+
# locations are actual places in the world
|
5
|
+
# they are in latitude and longitude (EPSG:4326)
|
6
|
+
Location = Struct.new(:lat, :lon) do
|
7
|
+
def to_coordinate(zoom: 0)
|
8
|
+
lat_rad = deg2rad(lat)
|
9
|
+
n = 2.0**zoom
|
10
|
+
column = ((lon + 180) / 360.0 * n)
|
11
|
+
row = ((1 - Math.log(Math.tan(lat_rad) + (1 / Math.cos(lat_rad))) / Math::PI) / 2 * n)
|
12
|
+
|
13
|
+
Coordinate.new(row, column, zoom)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
# degrees to radians
|
19
|
+
def deg2rad(degrees)
|
20
|
+
(degrees * Math::PI) / 180
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/honua/map.rb
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'async'
|
4
|
+
require 'forwardable'
|
5
|
+
require 'ruby-vips'
|
6
|
+
|
7
|
+
module Honua
|
8
|
+
class Map
|
9
|
+
extend Forwardable
|
10
|
+
|
11
|
+
attr_reader :width, :height, :zoom
|
12
|
+
|
13
|
+
def initialize(center:, width:, height:, zoom:)
|
14
|
+
@width = width
|
15
|
+
@height = height
|
16
|
+
@reference, @offset = reference_point(center.zoom_to(zoom))
|
17
|
+
@tiles = []
|
18
|
+
@zoom = zoom
|
19
|
+
end
|
20
|
+
|
21
|
+
def_delegators 'Honua.configuration',
|
22
|
+
:tile_width, :tile_height,
|
23
|
+
:attribution_text, :attribution_fgcolor, :attribution_bgcolor
|
24
|
+
|
25
|
+
def draw
|
26
|
+
fetch_tiles
|
27
|
+
render
|
28
|
+
end
|
29
|
+
|
30
|
+
# return an x,y point on the map image for a geographical location
|
31
|
+
def location2point(location)
|
32
|
+
x = @offset.x
|
33
|
+
y = @offset.y
|
34
|
+
coordinate = location.to_coordinate(zoom: @reference.zoom)
|
35
|
+
|
36
|
+
# distance from the know coordinate offset
|
37
|
+
x += tile_width * (coordinate.column - @reference.column)
|
38
|
+
y += tile_height * (coordinate.row - @reference.row)
|
39
|
+
|
40
|
+
x += @width / 2
|
41
|
+
y += @height / 2
|
42
|
+
|
43
|
+
Point.new(x.to_i, y.to_i)
|
44
|
+
end
|
45
|
+
|
46
|
+
def point2location(point)
|
47
|
+
# TODO: to be implemented
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
# returns the initial tile coordinate and its offset relative to the map center
|
53
|
+
def reference_point(coordinate)
|
54
|
+
# top left coordinate of tile containing center coordinate
|
55
|
+
top_left_coordinate = coordinate.container
|
56
|
+
|
57
|
+
# initial tile position offset, assuming centered tile in grid
|
58
|
+
offset_x = ((top_left_coordinate.column - coordinate.column) * Honua.configuration.tile_width).round
|
59
|
+
offset_y = ((top_left_coordinate.row - coordinate.row) * Honua.configuration.tile_height).round
|
60
|
+
offset = Point.new(offset_x, offset_y)
|
61
|
+
|
62
|
+
[top_left_coordinate, offset]
|
63
|
+
end
|
64
|
+
|
65
|
+
def fetch_tiles
|
66
|
+
coordinate, corner = top_left
|
67
|
+
|
68
|
+
Async do
|
69
|
+
(corner.y..@height).step(tile_height).each do |y|
|
70
|
+
current_coordinate = coordinate.dup
|
71
|
+
(corner.x..@width).step(tile_width).each do |x|
|
72
|
+
Async do
|
73
|
+
@tiles << Tile.get(current_coordinate, Point.new(x, y))
|
74
|
+
end
|
75
|
+
current_coordinate = current_coordinate.right
|
76
|
+
end
|
77
|
+
coordinate = coordinate.down
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# get top left coordinate and offset to map
|
83
|
+
def top_left
|
84
|
+
x_shift = 0
|
85
|
+
y_shift = 0
|
86
|
+
|
87
|
+
corner = Point.new(@offset.x + @width / 2, @offset.y + @height / 2)
|
88
|
+
|
89
|
+
# move left on the map until we have the starting coordinate and offset
|
90
|
+
while corner.x.positive?
|
91
|
+
corner.x -= tile_width
|
92
|
+
x_shift += 1
|
93
|
+
end
|
94
|
+
|
95
|
+
# move up on the map until we have the starting coordinate and offset
|
96
|
+
while corner.y.positive?
|
97
|
+
corner.y -= tile_height
|
98
|
+
y_shift += 1
|
99
|
+
end
|
100
|
+
|
101
|
+
coordinate = Coordinate.new(@reference.row - y_shift, @reference.column - x_shift, @reference.zoom)
|
102
|
+
|
103
|
+
[coordinate, corner]
|
104
|
+
end
|
105
|
+
|
106
|
+
# create a canvas and draw tile images based on their offset onto it
|
107
|
+
def render
|
108
|
+
canvas = Vips::Image.grey(@width, @height)
|
109
|
+
|
110
|
+
@tiles.each do |tile|
|
111
|
+
canvas = canvas.insert(tile.image, tile.offset.x, tile.offset.y) # rubocop:disable Style/RedundantSelfAssignment
|
112
|
+
end
|
113
|
+
|
114
|
+
# add attribution image to bottom corner if available & attribution fits into image
|
115
|
+
if add_attribution?
|
116
|
+
options = { x: canvas.width - attribution.width, y: canvas.height - attribution.height }
|
117
|
+
canvas = canvas.composite2(attribution, :over, **options)
|
118
|
+
end
|
119
|
+
|
120
|
+
canvas
|
121
|
+
end
|
122
|
+
|
123
|
+
# create attribution image
|
124
|
+
def attribution
|
125
|
+
@attribution ||= begin
|
126
|
+
mask = Vips::Image.text(attribution_text)
|
127
|
+
mask = mask.embed(4, 2, mask.width + 8, mask.height + 4)
|
128
|
+
mask.ifthenelse(Helpers.hex2rgb(attribution_fgcolor), Helpers.hex2rgb(attribution_bgcolor), blend: true)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def add_attribution?
|
133
|
+
!attribution_text.nil? && attribution.width < @width && attribution.height < @height
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
data/lib/honua/tile.rb
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require 'open-uri'
|
5
|
+
require 'ruby-vips'
|
6
|
+
|
7
|
+
# a tile is an image file representing a coordinate at a certain zoom level
|
8
|
+
# * request a tile from the tile server for a coordinate
|
9
|
+
# * use scaled version if not available at on of the next zoom levels
|
10
|
+
# * if row or column is out of bounds => start from the left again
|
11
|
+
# * if max_fetch_attempts are reached => use empty tile
|
12
|
+
module Honua
|
13
|
+
class Tile
|
14
|
+
extend Forwardable
|
15
|
+
|
16
|
+
attr_reader :image, :offset, :coordinate
|
17
|
+
|
18
|
+
def self.get(coordinate, offset)
|
19
|
+
tile = Tile.new(coordinate, offset)
|
20
|
+
tile.fetch
|
21
|
+
|
22
|
+
tile
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(coordinate, offset)
|
26
|
+
@coordinate = coordinate
|
27
|
+
@offset = offset
|
28
|
+
|
29
|
+
@image = blank_tile
|
30
|
+
end
|
31
|
+
|
32
|
+
def_delegators :@coordinate, :column, :row, :zoom
|
33
|
+
def_delegators 'Honua.configuration', :max_fetch_attempts, :tiles_url, :tile_width, :tile_height, :user_agent
|
34
|
+
|
35
|
+
# fetch tile image through url that includes zoom level and the tile grid's x/y coordinates
|
36
|
+
def fetch(attempt = 1, scale = 1)
|
37
|
+
if attempt >= max_fetch_attempts
|
38
|
+
@image = blank_tile
|
39
|
+
else
|
40
|
+
raw_tile = if url.start_with?('file://')
|
41
|
+
File.read(url.gsub('file://', ''))
|
42
|
+
else
|
43
|
+
URI.parse(url).open('User-Agent' => user_agent, &:read)
|
44
|
+
end
|
45
|
+
|
46
|
+
@image = Vips::Image.new_from_buffer(raw_tile, '')
|
47
|
+
end
|
48
|
+
|
49
|
+
@image = @image.resize(scale) if scale > 1
|
50
|
+
rescue OpenURI::HTTPError, Errno::ENOENT
|
51
|
+
old_row = @coordinate.row
|
52
|
+
old_column = @coordinate.column
|
53
|
+
|
54
|
+
# out of bounds columns or rows => try starting from the left or top
|
55
|
+
@coordinate.row = @coordinate.row - (2**@coordinate.zoom) if @coordinate.row > (2**@coordinate.zoom - 1)
|
56
|
+
@coordinate.column = @coordinate.column - (2**@coordinate.zoom) if @coordinate.column > (2**@coordinate.zoom - 1)
|
57
|
+
|
58
|
+
@coordinate.row = @coordinate.row + (2**@coordinate.zoom) if @coordinate.row.negative?
|
59
|
+
@coordinate.column = @coordinate.column + (2**@coordinate.zoom) if @coordinate.column.negative?
|
60
|
+
|
61
|
+
return fetch(attempt + 1) if old_row != @coordinate.row || old_column != @coordinate.column # if out of bounds
|
62
|
+
|
63
|
+
# if tile was not available try next zoom level and scale
|
64
|
+
recalculate!(scale)
|
65
|
+
fetch(attempt + 1, scale * 2)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# try the next lower zoom level if tile is not available
|
71
|
+
def recalculate!(scale)
|
72
|
+
neighbor = @coordinate.zoom_to(zoom - 1)
|
73
|
+
parent = neighbor.container
|
74
|
+
|
75
|
+
col_shift = 2 * (neighbor.column - parent.column)
|
76
|
+
row_shift = 2 * (neighbor.row - parent.row)
|
77
|
+
|
78
|
+
@offset.x -= scale * tile_width * col_shift
|
79
|
+
@offset.y -= scale * tile_height * row_shift
|
80
|
+
@coordinate = parent
|
81
|
+
end
|
82
|
+
|
83
|
+
def url
|
84
|
+
format(tiles_url, zoom: zoom, column: column, row: row)
|
85
|
+
end
|
86
|
+
|
87
|
+
def blank_tile
|
88
|
+
Vips::Image.grey(tile_width, tile_height)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
metadata
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: honua
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Joesi D.
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-02-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: async
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.28'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.28'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: ruby-vips
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: zeitwerk
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2.4'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2.4'
|
55
|
+
description: |
|
56
|
+
A mapping library to stitch geographic map images based on map tiles
|
57
|
+
provided by a rastered tile server.
|
58
|
+
email:
|
59
|
+
- dingsdax@fastmail.fm
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files:
|
63
|
+
- README.md
|
64
|
+
- LICENSE
|
65
|
+
files:
|
66
|
+
- LICENSE
|
67
|
+
- README.md
|
68
|
+
- lib/honua.rb
|
69
|
+
- lib/honua/configuration.rb
|
70
|
+
- lib/honua/coordinate.rb
|
71
|
+
- lib/honua/helpers.rb
|
72
|
+
- lib/honua/identity.rb
|
73
|
+
- lib/honua/location.rb
|
74
|
+
- lib/honua/map.rb
|
75
|
+
- lib/honua/tile.rb
|
76
|
+
homepage: https://github.com/dingsdax/honua
|
77
|
+
licenses:
|
78
|
+
- MIT
|
79
|
+
metadata:
|
80
|
+
bug_tracker_uri: https://github.com/dingsdax/honua/issues
|
81
|
+
changelog_uri: https://github.com/dingsdax/honua/blob/master/CHANGES.md
|
82
|
+
documentation_uri: https://github.com/dingsdax/honua
|
83
|
+
source_code_uri: https://github.com/dingsdax/honua
|
84
|
+
post_install_message:
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - "~>"
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '3.0'
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubygems_version: 3.2.3
|
100
|
+
signing_key:
|
101
|
+
specification_version: 4
|
102
|
+
summary: A Ruby geographic mapping library
|
103
|
+
test_files: []
|