map_view 0.0.1a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 68615351a014bd78c102aecfcfceab2c5b8f95fc40c5d002f7562753696eb047
4
+ data.tar.gz: 5041b59a1f06e55cf775ad52d42f9f86a205516ef6fe22515421af669efed106
5
+ SHA512:
6
+ metadata.gz: 8e2790b312928191a465fb42403126e69d567178946231659e68594e0d60b9cfad4ab61b246d9faa951699a2f47a09179d70d12df4908217bf48fde1109f39cd
7
+ data.tar.gz: b09a79564328702cb532d8cdea8e109a11f18d675c2e1e645d725a1147292d8d044cdad7c1586f05684040f265f61ec77f477298557e3be571a21059c8d7779b
data/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # LibGD-GIS
2
+
3
+ <p align="center">
4
+ <a href="https://rubystacknews.com/2026/01/07/ruby-can-now-draw-maps-and-i-started-with-ice-cream/">
5
+ <img src="https://img.shields.io/badge/RubyStackNews-CC342D?style=for-the-badge&logo=ruby&logoColor=white" />
6
+ </a>
7
+ <a href="https://x.com/ruby_stack_news">
8
+ <img src="https://img.shields.io/badge/Twitter%20@RubyStackNews-1DA1F2?style=for-the-badge&logo=twitter&logoColor=white" />
9
+ </a>
10
+ <a href="https://www.linkedin.com/in/germ%C3%A1n-silva-56a12622/">
11
+ <img src="https://img.shields.io/badge/Germán%20Silva-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white" />
12
+ </a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://rubygems.org/gems/libgd-gis">
17
+ <img src="https://img.shields.io/badge/RubyGems-libgd--gis-CC342D?style=for-the-badge&logo=rubygems&logoColor=white" />
18
+ </a>
19
+ <a href="https://github.com/ggerman/libgd-gis">
20
+ <img src="https://img.shields.io/badge/GitHub-libgd--gis-181717?style=for-the-badge&logo=github&logoColor=white" />
21
+ </a>
22
+ <a href="https://github.com/ggerman/ruby-libgd">
23
+ <img src="https://img.shields.io/badge/Engine-ruby--libgd-CC342D?style=for-the-badge&logo=ruby&logoColor=white" />
24
+ </a>
25
+ </p>
26
+
27
+ <p align="right">
28
+ <img src="docs/images/logo-gis.png" width="160" />
29
+ </p>
30
+
31
+ ![CI](https://github.com/ggerman/libgd-gis/actions/workflows/ci.yml/badge.svg)
32
+ [![Codacy Badge](https://api.codacy.com/project/badge/Grade/6bc3e7d6118d47e6959b16690b815909)](https://www.codacy.com/app/libgd-gis/libgd-gis?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=libgd-gis/libgd-gis&amp;utm_campaign=Badge_Grade)
33
+ [![Test Coverage](https://coveralls.io/repos/githublibgd-gis/libgd-gis/badge.svg?branch=master)](https://coveralls.io/github/libgd-gis/libgd-gis?branch=master)
34
+ [![Gem Version](https://img.shields.io/gem/v/libgd-gis.svg)](https://rubygems.org/gems/libgd-gis)
35
+
36
+
37
+ | Examples | Examples | Examples |
38
+ | :----: | :----: | :--: |
39
+ | <img src="docs/examples/parana.png" height="250"> | <img src="docs/examples/nyc.png" height="250"> | <img src="docs/examples/paris.png" height="250"> |
40
+ | <img src="examples/nyc/nyc.png" height="250"> | <img src="docs/examples/tokyo_solarized.png" height="250"> | <img src="examples/parana/parana.png" height="250"> |
41
+ | <img src="docs/examples/america.png" height="250"> | <img src="docs/examples/argentina_museum.png" height="250"> | <img src="docs/examples/museos_parana.png" height="250"> |
42
+ | <img src="docs/examples/asia.png" height="250"> | <img src="docs/examples/europe.png" height="250"> | <img src="docs/examples/icecream_parana.png" height="250"> |
43
+ | <img src="docs/examples/argentina_cities.png" height="250"> | <img src="docs/examples/tanzania_hydro.png" height="250"> | <img src="docs/examples/parana_polygon.png" height="250"> |
44
+ | <img src="docs/examples/parana_carto_dark.png" height="250"> | <img src="docs/examples/ramirez_avenue.png" height="250"> | <img src="examples/paris/paris.png" height="250"> |
45
+
46
+ ---
47
+
48
+ > **libgd-gis is evolving very fast**, so some examples may temporarily stop working.
49
+ > Please report issues or ask for help — feedback is very welcome.
50
+ > https://github.com/ggerman/libgd-gis/issues or ggerman@gmail.com
51
+
52
+ --
53
+
54
+ ## A geospatial raster engine for Ruby.
55
+
56
+ libgd-gis allows Ruby to render real maps, GeoJSON layers, vector features, and geospatial tiles using a native raster backend powered by **libgd**.
57
+
58
+ It restores something Ruby lost over time:
59
+ the ability to generate **maps, tiles, and GIS-grade visualizations natively**, without relying on external tools like QGIS, Mapnik, ImageMagick, or Mapbox.
60
+
61
+ Built on top of **ruby-libgd**, this project turns Ruby into a **map rendering engine**, capable of producing spatial graphics, tiled maps, and geospatial outputs directly inside Ruby processes.
62
+
63
+ - No external renderers.
64
+ - No shelling out.
65
+ - Just Ruby, raster, and GIS.
66
+
67
+ ---
68
+
69
+ ## What is this?
70
+
71
+ `libgd-gis` is a **geospatial rendering engine** for Ruby built on top of [`ruby-libgd`](https://github.com/ggerman/ruby-libgd).
72
+
73
+ It allows you to:
74
+
75
+ - Load GeoJSON, CSV, or any dataset with coordinates
76
+ - Fetch real basemap tiles
77
+ - Reproject WGS84 (lat/lon) into Web Mercator
78
+ - Render points, icons, and layers onto a raster map
79
+ - Generate PNG maps or map tiles
80
+
81
+ This is the same type of pipeline used by professional GIS systems — implemented in Ruby.
82
+
83
+ ---
84
+
85
+ ## Installation
86
+
87
+ ### System dependency
88
+
89
+ `libgd-gis` depends on **libgd**, via `ruby-libgd`.
90
+
91
+ Install libgd first:
92
+
93
+ **Ubuntu / Debian**
94
+ ```
95
+ sudo apt install libgd-dev
96
+ ```
97
+
98
+ **macOS**
99
+ ```
100
+ brew install gd
101
+ ```
102
+
103
+ ---
104
+
105
+ ### Ruby gems
106
+
107
+ ```
108
+ gem install ruby-libgd
109
+ gem install libgd-gis
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Quick Example
115
+
116
+ Render hydroelectric plants from a GeoJSON file:
117
+
118
+ ```ruby
119
+ require "json"
120
+ require "gd/gis"
121
+
122
+ # ---------------------------
123
+ # Bounding box mundial
124
+ # ---------------------------
125
+ AMERICA = [-170, -60, -30, 75]
126
+
127
+ # ---------------------------
128
+ # Crear mapa
129
+ # ---------------------------
130
+ map = GD::GIS::Map.new(
131
+ bbox: AMERICA,
132
+ zoom: 4,
133
+ basemap: :carto_light
134
+ )
135
+
136
+ # Cargar datos
137
+ # ---------------------------
138
+ peaks = JSON.parse(File.read("picks.json"))
139
+
140
+ # ---------------------------
141
+ # Agregar capa de puntos
142
+ # ---------------------------
143
+ map.add_points(
144
+ peaks,
145
+ lon: ->(p) { p["longitude"] },
146
+ lat: ->(p) { p["latitude"] },
147
+ icon: "peak.png",
148
+ label: ->(p) { p["name"] },
149
+ font: "./fonts/DejaVuSans.ttf",
150
+ size: 10,
151
+ color: [0,0,0]
152
+ )
153
+
154
+ # ---------------------------
155
+ # Renderizar y guardar
156
+ # ---------------------------
157
+ map.render
158
+ map.save("output/america.png")
159
+
160
+ puts "Saved output/america.png"
161
+
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Features
167
+
168
+ - Real basemap tiles
169
+ - WGS84 → Web Mercator projection
170
+ - GeoJSON point rendering
171
+ - CSV / JSON support
172
+ - Icon-based symbol layers
173
+ - Automatic bounding box fitting
174
+ - Raster output (PNG)
175
+
176
+ ---
177
+
178
+ ## License
179
+
180
+ MIT
181
+
182
+ ---
183
+
184
+ ## Author
185
+
186
+ Germán Silva
187
+ https://github.com/ggerman
188
+ https://rubystacknews.com
@@ -0,0 +1,178 @@
1
+ require "net/http"
2
+ require "fileutils"
3
+
4
+ module GD
5
+ module GIS
6
+ class Basemap
7
+ TILE_SIZE = 256
8
+ attr_reader :origin_x, :origin_y
9
+
10
+ def initialize(zoom, bbox, provider=:carto_light)
11
+ @zoom = zoom
12
+ @bbox = bbox
13
+ @provider = provider
14
+ end
15
+
16
+ def url(z, x, y, style = :osm)
17
+ case style
18
+
19
+ # ==============================
20
+ # OpenStreetMap
21
+ # ==============================
22
+ when :osm
23
+ "https://tile.openstreetmap.org/#{z}/#{x}/#{y}.png"
24
+
25
+ when :osm_hot
26
+ "https://tile.openstreetmap.fr/hot/#{z}/#{x}/#{y}.png"
27
+
28
+ when :osm_fr
29
+ "https://a.tile.openstreetmap.fr/osmfr/#{z}/#{x}/#{y}.png"
30
+
31
+ # ==============================
32
+ # CARTO
33
+ # ==============================
34
+ when :carto_light
35
+ "https://a.basemaps.cartocdn.com/light_all/#{z}/#{x}/#{y}.png"
36
+
37
+ when :carto_light_nolabels
38
+ "https://a.basemaps.cartocdn.com/light_nolabels/#{z}/#{x}/#{y}.png"
39
+
40
+ when :carto_dark
41
+ "https://a.basemaps.cartocdn.com/dark_all/#{z}/#{x}/#{y}.png"
42
+
43
+ when :carto_dark_nolabels
44
+ "https://a.basemaps.cartocdn.com/dark_nolabels/#{z}/#{x}/#{y}.png"
45
+
46
+ # ==============================
47
+ # ESRI / ArcGIS (Satellite, terrain, hybrid)
48
+ # ==============================
49
+ when :esri_satellite
50
+ "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/#{z}/#{y}/#{x}"
51
+
52
+ when :esri_streets
53
+ "https://services.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/#{z}/#{y}/#{x}"
54
+
55
+ when :esri_terrain
56
+ "https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/#{z}/#{y}/#{x}"
57
+
58
+ when :esri_hybrid
59
+ "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/#{z}/#{y}/#{x}"
60
+
61
+ # ==============================
62
+ # STAMEN 503
63
+ # ==============================
64
+ when :stamen_toner
65
+ "https://stamen-tiles.a.ssl.fastly.net/toner/#{z}/#{x}/#{y}.png"
66
+
67
+ when :stamen_toner_lite
68
+ "https://stamen-tiles.a.ssl.fastly.net/toner-lite/#{z}/#{x}/#{y}.png"
69
+
70
+ when :stamen_terrain
71
+ "https://stamen-tiles.a.ssl.fastly.net/terrain/#{z}/#{x}/#{y}.png"
72
+
73
+ when :stamen_watercolor
74
+ "https://stamen-tiles.a.ssl.fastly.net/watercolor/#{z}/#{x}/#{y}.jpg"
75
+
76
+ # ==============================
77
+ # OpenTopoMap
78
+ # ==============================
79
+ when :topo
80
+ "https://a.tile.opentopomap.org/#{z}/#{x}/#{y}.png"
81
+
82
+ # ==============================
83
+ # Wikimedia 403
84
+ # ==============================
85
+ when :wikimedia
86
+ "https://maps.wikimedia.org/osm-intl/#{z}/#{x}/#{y}.png"
87
+
88
+ # ==============================
89
+ # OpenRailwayMap
90
+ # ==============================
91
+ when :railway
92
+ "https://tiles.openrailwaymap.org/standard/#{z}/#{x}/#{y}.png"
93
+
94
+ # ==============================
95
+ # CyclOSM
96
+ # ==============================
97
+ when :cyclosm
98
+ "https://a.tile-cyclosm.openstreetmap.fr/cyclosm/#{z}/#{x}/#{y}.png"
99
+
100
+ else
101
+ raise "Unknown basemap style: #{style}"
102
+ end
103
+ end
104
+
105
+ def lon2tile(lon)
106
+ ((lon + 180.0) / 360.0 * (2 ** @zoom)).floor
107
+ end
108
+
109
+ def lat2tile(lat)
110
+ rad = lat * Math::PI / 180
111
+ ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math::PI) / 2 * (2 ** @zoom)).floor
112
+ end
113
+
114
+ def fetch_tiles
115
+ west, south, east, north = @bbox
116
+
117
+ x_min = lon2tile(west)
118
+ x_max = lon2tile(east)
119
+ y_min = lat2tile(north)
120
+ y_max = lat2tile(south)
121
+
122
+ @x_min = x_min
123
+ @y_min = y_min
124
+
125
+ @origin_x = x_min * TILE_SIZE
126
+ @origin_y = y_min * TILE_SIZE
127
+
128
+ FileUtils.mkdir_p("tmp/tiles")
129
+
130
+ tiles = []
131
+
132
+ (x_min..x_max).each do |x|
133
+ (y_min..y_max).each do |y|
134
+ path = nil
135
+
136
+ unless File.exist?("tmp/tiles/#{@provider}_#{@zoom}_#{x}_#{y}.png") ||
137
+ File.exist?("tmp/tiles/#{@provider}_#{@zoom}_#{x}_#{y}.jpg")
138
+
139
+ uri = URI(url(@zoom, x, y, @provider))
140
+
141
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
142
+ req = Net::HTTP::Get.new(uri)
143
+ req["User-Agent"] = "libgd-gis/0.1 (Ruby)"
144
+
145
+ res = http.request(req)
146
+ raise "Tile fetch failed #{res.code}" unless res.code == "200"
147
+
148
+ content_type = res["content-type"]
149
+
150
+ ext =
151
+ if content_type&.include?("png")
152
+ "png"
153
+ elsif content_type&.include?("jpeg") || content_type&.include?("jpg")
154
+ "jpg"
155
+ else
156
+ raise "Unsupported tile type: #{content_type}"
157
+ end
158
+
159
+ path = "tmp/tiles/#{@provider}_#{@zoom}_#{x}_#{y}.#{ext}"
160
+ File.binwrite(path, res.body)
161
+ end
162
+ else
163
+ if File.exist?("tmp/tiles/#{@provider}_#{@zoom}_#{x}_#{y}.png")
164
+ path = "tmp/tiles/#{@provider}_#{@zoom}_#{x}_#{y}.png"
165
+ else
166
+ path = "tmp/tiles/#{@provider}_#{@zoom}_#{x}_#{y}.jpg"
167
+ end
168
+ end
169
+
170
+ tiles << [x,y,path]
171
+ end
172
+ end
173
+
174
+ [tiles, x_min, y_min]
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,57 @@
1
+ module GD
2
+ module GIS
3
+ class Classifier
4
+ def self.road(feature)
5
+ tags = feature.properties || {}
6
+
7
+ case tags["highway"]
8
+ when "motorway", "trunk"
9
+ :motorway
10
+ when "primary", "primary_link"
11
+ :primary
12
+ when "secondary", "secondary_link"
13
+ :secondary
14
+ when "tertiary"
15
+ :street
16
+ when "residential", "living_street"
17
+ :street
18
+ when "service", "track"
19
+ :minor
20
+ else
21
+ nil
22
+ end
23
+ end
24
+
25
+ def self.water?(feature)
26
+ p = feature.properties
27
+
28
+ p["waterway"] ||
29
+ p["natural"] == "water" ||
30
+ p["fclass"] == "river" ||
31
+ p["fclass"] == "stream"
32
+ end
33
+
34
+ def self.rail?(feature)
35
+ tags = feature.properties || {}
36
+ tags["railway"]
37
+ end
38
+
39
+ def self.park?(feature)
40
+ tags = feature.properties || {}
41
+ %w[park recreation_ground garden].include?(tags["leisure"]) ||
42
+ %w[park grass forest].include?(tags["landuse"])
43
+ end
44
+
45
+ def self.water_kind(feature)
46
+ p = feature.properties
47
+
48
+ case p["waterway"] || p["fclass"]
49
+ when "river" then :river
50
+ when "stream" then :stream
51
+ else :minor
52
+ end
53
+ end
54
+
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,65 @@
1
+ module GD
2
+ module GIS
3
+ module ColorHelpers
4
+ # --------------------------------------------------
5
+ # Random RGB color
6
+ # --------------------------------------------------
7
+ def self.random_rgb(min: 0, max: 255)
8
+ GD::Color.rgb(
9
+ rand(min..max),
10
+ rand(min..max),
11
+ rand(min..max)
12
+ )
13
+ end
14
+
15
+ # --------------------------------------------------
16
+ # Random RGBA color
17
+ # --------------------------------------------------
18
+ def self.random_rgba(min: 0, max: 255, alpha: nil)
19
+ GD::Color.rgba(
20
+ rand(min..max),
21
+ rand(min..max),
22
+ rand(min..max),
23
+ alpha || rand(50..255)
24
+ )
25
+ end
26
+
27
+ # --------------------------------------------------
28
+ # Random vivid color (avoid gray/mud)
29
+ # --------------------------------------------------
30
+ def self.random_vivid
31
+ h = rand
32
+ s = rand(0.6..1.0)
33
+ v = rand(0.7..1.0)
34
+
35
+ r, g, b = hsv_to_rgb(h, s, v)
36
+ GD::Color.rgb(r, g, b)
37
+ end
38
+
39
+ # --------------------------------------------------
40
+ # HSV → RGB
41
+ # --------------------------------------------------
42
+ def self.hsv_to_rgb(h, s, v)
43
+ i = (h * 6).floor
44
+ f = h * 6 - i
45
+ p = v * (1 - s)
46
+ q = v * (1 - f * s)
47
+ t = v * (1 - (1 - f) * s)
48
+
49
+ r, g, b =
50
+ case i % 6
51
+ when 0 then [v, t, p]
52
+ when 1 then [q, v, p]
53
+ when 2 then [p, v, t]
54
+ when 3 then [p, q, v]
55
+ when 4 then [t, p, v]
56
+ when 5 then [v, p, q]
57
+ end
58
+
59
+ [(r * 255).to_i, (g * 255).to_i, (b * 255).to_i]
60
+ end
61
+
62
+ end
63
+ end
64
+ end
65
+
@@ -0,0 +1,57 @@
1
+ module GD
2
+ module GIS
3
+ module CRS
4
+ CRS84 = "urn:ogc:def:crs:OGC:1.3:CRS84"
5
+ EPSG4326 = "EPSG:4326"
6
+ EPSG3857 = "EPSG:3857"
7
+
8
+ class Normalizer
9
+ def initialize(crs)
10
+ @crs = normalize_name(crs)
11
+ end
12
+
13
+ # Accepts:
14
+ # normalize(lon,lat)
15
+ # normalize(lon,lat,z)
16
+ # normalize([lon,lat])
17
+ # normalize([lon,lat,z])
18
+ def normalize(*args)
19
+ lon, lat = args.flatten
20
+ return nil if lon.nil? || lat.nil?
21
+
22
+ lon = lon.to_f
23
+ lat = lat.to_f
24
+
25
+ case @crs
26
+ when CRS84, nil
27
+ [lon, lat]
28
+
29
+ when EPSG4326
30
+ # axis order lat,lon → lon,lat
31
+ [lat, lon]
32
+
33
+ when EPSG3857
34
+ mercator_to_wgs84(lon, lat)
35
+
36
+ else
37
+ [lon, lat]
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def normalize_name(name)
44
+ return nil if name.nil?
45
+ name.to_s.strip
46
+ end
47
+
48
+ def mercator_to_wgs84(x, y)
49
+ r = 6378137.0
50
+ lon = (x / r) * 180.0 / Math::PI
51
+ lat = (2 * Math.atan(Math.exp(y / r)) - Math::PI / 2) * 180.0 / Math::PI
52
+ [lon, lat]
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end