Class: GD::GIS::Map

Inherits:
Object
  • Object
show all
Defined in:
lib/gd/gis/map.rb

Overview

Represents a complete renderable map.

A Map is responsible for:

  • Managing the geographic extent (bounding box + zoom)

  • Fetching and compositing basemap tiles

  • Managing semantic feature layers

  • Managing generic overlay layers (points, lines, polygons)

  • Executing the rendering pipeline

The map can render either:

  • A full tile-based image, or

  • A fixed-size viewport clipped from the basemap

Constant Summary collapse

TILE_SIZE =

Tile size in pixels (Web Mercator standard)

256

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(bbox:, zoom:, basemap:, width: nil, height: nil, crs: nil, fitted_bbox: false) ⇒ Map

Creates a new map.

Parameters:

  • bbox (Array<Float>)

    bounding box [min_lng, min_lat, max_lng, max_lat]

  • zoom (Integer)

    zoom level

  • basemap (Symbol)

    basemap provider identifier

  • width (Integer, nil) (defaults to: nil)

    viewport width in pixels

  • height (Integer, nil) (defaults to: nil)

    viewport height in pixels

  • crs (String, Symbol, nil) (defaults to: nil)

    input CRS identifier

  • fitted_bbox (Boolean) (defaults to: false)

    whether the provided bbox is already viewport-fitted

Raises:

  • (ArgumentError)

    if parameters are invalid



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/gd/gis/map.rb', line 64

def initialize(
  bbox:,
  zoom:,
  basemap:,
  width: nil,
  height: nil,
  crs: nil,
  fitted_bbox: false
)
  # resolve symbolic bbox
  bbox = GD::GIS::BBoxResolver.resolve(bbox)

  # 1. Basic input validation
  raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]" unless
    bbox.is_a?(Array) && bbox.size == 4

  raise ArgumentError, "zoom must be an Integer" unless zoom.is_a?(Integer)

  raise ArgumentError, "width and height must be provided together" if (width && !height) || (!width && height)

  @zoom   = zoom
  @width  = width
  @height = height

  # 2. CRS normalization (input → WGS84 lon/lat)
  if crs
    normalizer = GD::GIS::CRS::Normalizer.new(crs)

    min_lng, min_lat = normalizer.normalize(bbox[0], bbox[1])
    max_lng, max_lat = normalizer.normalize(bbox[2], bbox[3])

    bbox = [min_lng, min_lat, max_lng, max_lat]
  end

  # 3. Final bbox (viewport-aware if width/height)
  @bbox =
    if width && height && !fitted_bbox
      GD::GIS::Geometry.viewport_bbox(
        bbox: bbox,
        zoom: zoom,
        width: width,
        height: height
      )
    else
      bbox
    end

  # 4. Basemap (uses FINAL bbox)
  @basemap = GD::GIS::Basemap.new(zoom, @bbox, basemap)

  # 5. Legacy semantic layers (REQUIRED by render)
  @layers = {
    motorway:  [],
    primary:   [],
    secondary: [],
    street:    [],
    track:     [],
    minor:     [],
    rail:      [],
    water:     [],
    park:      []
  }

  # Optional alias (semantic clarity, no behavior change)
  @road_layers = @layers

  # 6. Overlay layers (generic)
  @points_layers   = []
  @lines_layers    = []
  @polygons_layers = []

  @debug = false
  @used_labels = {}
  @count = 1
end

Instance Attribute Details

#debugBoolean (readonly)

Returns enables debug rendering.

Returns:

  • (Boolean)

    enables debug rendering



44
45
46
# File 'lib/gd/gis/map.rb', line 44

def debug
  @debug
end

#imageGD::Image? (readonly)

Returns rendered image.

Returns:

  • (GD::Image, nil)

    rendered image



35
36
37
# File 'lib/gd/gis/map.rb', line 35

def image
  @image
end

#layersHash<Symbol, Array> (readonly)

Returns semantic feature layers.

Returns:

  • (Hash<Symbol, Array>)

    semantic feature layers



38
39
40
# File 'lib/gd/gis/map.rb', line 38

def layers
  @layers
end

#styleObject?

Returns style object.

Returns:

  • (Object, nil)

    style object



41
42
43
# File 'lib/gd/gis/map.rb', line 41

def style
  @style
end

Instance Method Details

#add_geojson(source) ⇒ void

This method returns an undefined value.

Loads features from a GeoJSON file.

This method:

  • Normalizes CRS

  • Classifies features into semantic layers

  • Creates overlay layers when needed (points)

Parameters:

  • path (String)

    path to GeoJSON file



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/gd/gis/map.rb', line 337

def add_geojson(source)
  @style ||= GD::GIS::Style.default
  features = LayerGeoJSON.load(source)

  features.each do |feature|
    maybe_create_line_label(feature)

    case feature.layer
    when :water
      kind =
        case (feature.properties["objeto"] || feature.properties["waterway"]).to_s.downcase
        when /river|río|canal/	then :river
        when /stream|arroyo/	then :stream
        else :minor
        end

      @layers[:water] << [kind, feature]

    when :roads
      @layers[:street] << feature

    when :parks
      @layers[:park] << feature

    when :track
      @layers[:track] << feature
    else
      geom_type = feature.geometry["type"]

      if geom_type == "Point"
        points_style = @style.points || begin
          warn "Style error: missing 'points' section"
        end

        font = @style.points[:font] || begin
          warn "[libgd-gis] points.font not defined in style, using random system font"
          GD::GIS::FontHelper.random
        end

        size = @style.points[:size] || begin
          warn "[libgd-gis] points.font size not defined in style, using random system font size"
          (6..14).to_a.sample
        end

        color = @style.points[:color] ? @style.normalize_color(@style.points[:color]) : GD::GIS::ColorHelpers.random_vivid
        font_color = @style.points[:font_color] ? @style.normalize_color(@style.points[:font_color]) : [250, 250, 250, 0]

        icon  = if @style.points.key?(:icon_fill) && @style.points.key?(:icon_stroke)
                  [points_style[:icon_stroke],
                   points_style[:icon_stroke]]
                end
        icon  = points_style.key?(:icon) ? points_style[:icon] : nil if icon.nil?

        @points_layers << GD::GIS::PointsLayer.new(
          [feature],
          lon:   ->(f) { f.geometry["coordinates"][0] },
          lat:   ->(f) { f.geometry["coordinates"][1] },
          icon:  icon,
          label: ->(f) { f.properties["name"] },
          font:  font,
          size:  size,
          color: color,
          font_color: font_color,
          symbol: @count
        )
        @count += 1
      elsif LINE_GEOMS.include?(geom_type)
        @layers[:minor] << feature
      end
    end
    puts "\n=== ONTOLOGY DEBUG ==="
    puts "Properties: #{feature.properties}"
    puts "Geometry: #{feature.geometry['type']}"
    puts "→ Classified as: #{feature.layer.inspect}"
  end
end

#add_lines(features) ⇒ void

This method returns an undefined value.

Adds a generic lines overlay layer.

Parameters:

  • features (Array)
  • opts (Hash)


521
522
523
524
# File 'lib/gd/gis/map.rb', line 521

def add_lines(features, **)
  @style ||= GD::GIS::Style.default
  @lines_layers << GD::GIS::LinesLayer.new(features, **)
end

#add_point(lon:, lat:, label: nil, icon: nil, font: nil, size: nil, color: nil, font_color: nil, symbol: nil) ⇒ void

This method returns an undefined value.

Adds a single point (marker) to the map.

This is a convenience helper for the most common use case: rendering one point with an optional label and icon, without having to build a full collection or manually configure a PointsLayer.

Internally, this method wraps the given coordinates into a one-element data collection and delegates rendering to PointsLayer, preserving the same rendering behavior and options.

This method is intended for annotations, markers, alerts, cities, or any scenario where only one point needs to be rendered.

Examples:

Render a simple point

map.add_point(
  lon: -58.3816,
  lat: -34.6037
)

Point with label

map.add_point(
  lon: -58.3816,
  lat: -34.6037,
  label: "Buenos Aires"
)

Point with numeric marker

map.add_point(
  lon: -58.3816,
  lat: -34.6037,
  icon: "numeric",
  label: "1",
  font: "/usr/share/fonts/DejaVuSans.ttf"
)

Parameters:

  • lon (Numeric)

    Longitude of the point.

  • lat (Numeric)

    Latitude of the point.

  • label (String, nil) (defaults to: nil)

    Optional text label rendered next to the point.

  • icon (String, Array<GD::Color>, nil) (defaults to: nil)

    Marker representation. Can be:

    • a path to an image file

    • :numeric or :alphabetic symbol styles

    • an array of [fill, stroke] colors

    • nil to generate a default marker

  • font (String, nil) (defaults to: nil)

    Font path used to render the label or symbol.

  • size (Integer) (defaults to: nil)

    Font size in pixels (default: 12).

  • color (Array<Integer>) (defaults to: nil)

    Label or symbol background color as an RGB(A) array.

  • font_color (GD::Color, nil) (defaults to: nil)

    Text color for numeric or alphabetic symbols.



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
# File 'lib/gd/gis/map.rb', line 473

def add_point(
  lon:,
  lat:,
  label: nil,
  icon: nil,
  font: nil,
  size: nil,
  color: nil,
  font_color: nil,
  symbol: nil
)
  row = {
    lon: lon,
    lat: lat,
    label: label
  }

  @style ||= GD::GIS::Style.default

  @points_layers << GD::GIS::PointsLayer.new(
    [row],
    lon: ->(r) { r[:lon] },
    lat: ->(r) { r[:lat] },
    icon: icon || @style.point[:icon],
    label: label ? ->(r) { r[:label] } : nil,
    font: font || @style.point[:font],
    size: size || @style.point[:size],
    color: color || @style.point[:color],
    font_color: font_color || @style.point[:font_color],
    symbol: symbol
  )
end

#add_points(data) ⇒ void

This method returns an undefined value.

Adds a generic points overlay layer.

Parameters:

  • data (Enumerable)
  • opts (Hash)


511
512
513
514
# File 'lib/gd/gis/map.rb', line 511

def add_points(data, **)
  @style ||= GD::GIS::Style.default
  @points_layers << GD::GIS::PointsLayer.new(data, **)
end

#add_polygons(polygons) ⇒ void

This method returns an undefined value.

Adds a generic polygons overlay layer.

Parameters:

  • polygons (Array)
  • opts (Hash)


531
532
533
534
# File 'lib/gd/gis/map.rb', line 531

def add_polygons(polygons, **)
  @style ||= GD::GIS::Style.default
  @polygons_layers << GD::GIS::PolygonsLayer.new(polygons, **)
end

#draw_layer(kind, projection) ⇒ void

This method returns an undefined value.

Draws a semantic feature layer.

Parameters:

  • kind (Symbol)
  • projection (#call)


677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
# File 'lib/gd/gis/map.rb', line 677

def draw_layer(kind, projection)
  items = @layers[kind]
  return if items.nil? || items.empty?

  puts ">>>>>>debug<<<<<<"
  puts kind


  style =
    case kind
    when :street, :primary, :motorway, :secondary, :minor
      @style.roads[kind]
    when :track
      @style.track[kind]
    when :rail
      @style.rails
    when :water
      @style.water
    when :park
      @style.parks
    else
      @style.extra[kind] if @style.respond_to?(:extra)
    end

  return if style.nil?

  items.each do |item|
    if kind == :water
      water_kind, f = item

      width =
        case water_kind
        when :river  then 2.5
        when :stream then 1.5
        else 1
        end

      if style[:stroke]
        color = GD::Color.rgb(*style[:stroke])

        color = GD::GIS::ColorHelpers.random_vivid if @debug

        f.draw(@image, projection, color, width, :water)
      end
    else
      f = item
      geom = f.geometry["type"]

      if POLY_GEOMS.include?(geom)
        f.draw(@image, projection, nil, nil, style)
      elsif style[:stroke]
        r, g, b, a = style[:stroke]
        a = 0 if a.nil?
        color = GD::Color.rgba(r, g, b, a)

        color = GD::GIS::ColorHelpers.random_vivid if @debug

        width = style[:stroke_width] ? style[:stroke_width].round : 1
        width = 1 if width < 1
        f.draw(@image, projection, color, width)
      end
    end
  end
end

#draw_legendObject



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/gd/gis/map.rb', line 238

def draw_legend
  @style ||= GD::GIS::Style.default
  return unless @legend
  return unless @image
  return unless @style
  return unless @style.global
  return if @style.global[:label] == false

  label_style = @style.global[:label] || {}

  padding     = 10
  box_size    = 12
  line_height = 18
  margin      = 15

  # --- font (from style) -----------------------------------

  font_path =
    case label_style[:font]
    when nil, "default"
      GD::GIS::FontHelper.random
    else
      label_style[:font]
    end

  font_size  = label_style[:size] || 10
  font_color = GD::Color.rgba(*(label_style[:color] || [0, 0, 0, 0]))

  # --- measure text (CORRECT API) ---------------------------

  text_widths = @legend.items.map do |i|
    w, = @image.text_bbox(
      i.label,
      font: font_path,
      size: font_size
    )
    w
  end

  width  = (text_widths.max || 0) + box_size + (padding * 3)
  height = (@legend.items.size * line_height) + (padding * 2)

  # --- position --------------------------------------------

  x, y =
    case @legend.position
    when :bottom_right
      [@image.width - width - margin, @image.height - height - margin]
    when :bottom_left
      [margin, @image.height - height - margin]
    when :top_right
      [@image.width - width - margin, margin]
    else
      [margin, margin]
    end

  # --- background ------------------------------------------

  bg     = GD::Color.rgba(255, 255, 255, 80)
  border = GD::Color.rgb(200, 200, 200)

  @image.filled_rectangle(x, y, x + width, y + height, bg)
  @image.rectangle(x, y, x + width, y + height, border)

  # --- items -----------------------------------------------

  @legend.items.each_with_index do |item, idx|
    iy = y + padding + (idx * line_height)

    # color box
    @image.filled_rectangle(
      x + padding,
      iy,
      x + padding + box_size,
      iy + box_size,
      GD::Color.rgba(*item.color)
    )

    # label text
    @image.text_ft(
      item.label,
      x: x + padding + box_size + 8,
      y: iy + box_size,
      font: font_path,
      size: font_size,
      color: font_color
    )
  end
end

#featuresArray<Feature>

Returns all features in the map.

Returns:



155
156
157
158
159
# File 'lib/gd/gis/map.rb', line 155

def features
  @layers.values.flatten.map do |item|
    item.is_a?(Array) ? item.last : item
  end
end

#features_by_layer(layer) ⇒ Array<Feature>

Returns all features belonging to a given semantic layer.

Parameters:

  • layer (Symbol)

Returns:



144
145
146
147
148
149
150
# File 'lib/gd/gis/map.rb', line 144

def features_by_layer(layer)
  return [] unless @layers[layer]

  @layers[layer].map do |item|
    item.is_a?(Array) ? item.last : item
  end
end

#legend(position: :bottom_right) {|@legend| ... } ⇒ Object

Yields:



223
224
225
226
# File 'lib/gd/gis/map.rb', line 223

def legend(position: :bottom_right)
  @legend = Legend.new(position: position)
  yield @legend
end

#legend_from_layers(position: :bottom_right) ⇒ Object



228
229
230
231
232
233
234
235
236
# File 'lib/gd/gis/map.rb', line 228

def legend_from_layers(position: :bottom_right)
  @legend = Legend.new(position: position)

  layers.each do |layer|
    next unless layer.respond_to?(:color)

    @legend.add(layer.color, layer.name)
  end
end

#maybe_create_line_label(feature) ⇒ void

Note:

This method must be called during feature loading (add_geojson), before rendering. It intentionally does not depend on map style configuration, which is applied later during rendering.

This method returns an undefined value.

Creates a single text label for a named linear feature (LineString or MultiLineString), avoiding duplicate labels for the same named entity.

Many datasets (especially OSM) split a single logical entity (rivers, streets, railways, etc.) into multiple line features that all share the same name. This method ensures that:

  • Only one label is created per unique entity name

  • The label is placed on a representative segment of the geometry

  • The logic is independent of the feature’s semantic layer (water, road, rail)

Labels are rendered using a PointsLayer because libgd-gis does not support text rendering directly on line geometries.

The label position is chosen as the midpoint of the line coordinates. This is a simple heuristic that provides a reasonable placement without requiring geometry merging or topological analysis.

Parameters:

  • feature (GD::GIS::Feature)

    A feature with a linear geometry and a “name” property.



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/gd/gis/map.rb', line 189

def maybe_create_line_label(feature)
  @style ||= GD::GIS::Style.default
  return true if @style.global[:label] == false || @style.global[:label].nil?

  geom = feature.geometry
  return unless LINE_GEOMS.include?(geom["type"])

  name = feature.properties["name"]
  return if name.nil? || name.empty?

  key = feature.properties["wikidata"] || name
  return if @used_labels[key]

  coords = geom["coordinates"]
  coords = coords.flatten(1) if geom["type"] == "MultiLineString"
  return if coords.size < 2

  lon, lat = coords[coords.size / 2]

  @points_layers << GD::GIS::PointsLayer.new(
    [feature],
    lon:   ->(_) { lon },
    lat:   ->(_) { lat },
    icon: @style.global[:label][:icon],
    label: ->(_) { name },
    font:  @style.global[:label][:font] || GD::GIS::FontHelper.random,
    size:  @style.global[:label][:size] || (6..20).to_a.sample,
    color: @style.global[:label][:color] || GD::GIS::ColorHelpers.random_rgba,
    font_color: @style.global[:label][:color] || GD::GIS::ColorHelpers.random_rgba
  )

  @used_labels[key] = true
end

#rendervoid

This method returns an undefined value.

Renders the map.

Chooses between tile rendering and viewport rendering depending on whether width and height are set.

Raises:

  • (RuntimeError)

    if style is not set



543
544
545
546
547
548
549
550
551
552
# File 'lib/gd/gis/map.rb', line 543

def render
  # Style assign DEFAULT Styles
  raise "map.style must be set" unless @style

  if @width && @height
    render_viewport
  else
    render_tiles
  end
end

#render_tilesObject



554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
# File 'lib/gd/gis/map.rb', line 554

def render_tiles
  raise "map.style must be set" unless @style

  tiles, x_min, y_min = @basemap.fetch_tiles

  xs = tiles.map { |t| t[0] }
  ys = tiles.map { |t| t[1] }

  cols = xs.max - xs.min + 1
  rows = ys.max - ys.min + 1

  width  = cols * TILE_SIZE
  height = rows * TILE_SIZE

  origin_x = x_min * TILE_SIZE
  origin_y = y_min * TILE_SIZE

  @image = GD::Image.new(width, height)
  @image.antialias = false

  # Basemap
  tiles.each do |x, y, file|
    tile = GD::Image.open(file)
    @image.copy(
      tile,
      (x - x_min) * TILE_SIZE,
      (y - y_min) * TILE_SIZE,
      0, 0, TILE_SIZE, TILE_SIZE
    )
  end

  projection = lambda do |lon, lat|
    x, y = GD::GIS::Projection.lonlat_to_global_px(lon, lat, @zoom)
    [(x - origin_x).round, (y - origin_y).round]
  end

  # 1. GeoJSON semantic layers
  @style.order.each do |kind|
    draw_layer(kind, projection)
  end

  # 2. Generic overlays
  @polygons_layers.each { |l| l.render!(@image, projection) }
  @lines_layers.each    { |l| l.render!(@image, projection) }
  @points_layers.each   { |l| l.render!(@image, projection) }

  draw_legend
end

#render_viewportObject



603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
# File 'lib/gd/gis/map.rb', line 603

def render_viewport
  raise "map.style must be set" unless @style

  @image = GD::Image.new(@width, @height)
  @image.antialias = false

  # 1. Compute global pixel bbox
  min_lng, min_lat, max_lng, max_lat = @bbox

  x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
  GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)

  # 2. Fetch tiles
  tiles, = @basemap.fetch_tiles

  # 3. Draw tiles clipped to viewport
  tiles.each do |x, y, file|
    tile = GD::Image.open(file)

    tile_x = x * TILE_SIZE
    tile_y = y * TILE_SIZE

    dst_x = tile_x - x1
    dst_y = tile_y - y1

    src_x = [0, -dst_x].max
    src_y = [0, -dst_y].max

    draw_w = [TILE_SIZE - src_x, @width  - dst_x - src_x].min
    draw_h = [TILE_SIZE - src_y, @height - dst_y - src_y].min

    next if draw_w <= 0 || draw_h <= 0

    @image.copy(
      tile,
      dst_x + src_x,
      dst_y + src_y,
      src_x,
      src_y,
      draw_w,
      draw_h
    )
  end

  # 4. Projection (viewport version)
  projection = lambda do |lon, lat|
    GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
  end

  # 5. REUSE the same render pipeline
  @style.order.each do |kind|
    draw_layer(kind, projection)
  end

  @polygons_layers.each { |l| l.render!(@image, projection) }
  @lines_layers.each    { |l| l.render!(@image, projection) }
  @points_layers.each   { |l| l.render!(@image, projection) }

  draw_legend
end

#save(path) ⇒ void

This method returns an undefined value.

Saves the rendered image to disk.

Parameters:

  • path (String)


668
669
670
# File 'lib/gd/gis/map.rb', line 668

def save(path)
  @image.save(path)
end