Module: NSWTopo::Labels

Includes:
Log, Vector
Defined in:
lib/nswtopo/layer/labels.rb,
lib/nswtopo/layer/labels/fence.rb

Defined Under Namespace

Modules: LabelFeatures Classes: Fence, Label

Constant Summary collapse

CENTRELINE_FRACTION =
0.35
DEFAULT_SAMPLE =
5
INSET =
1
PROPERTIES =
%w[font-size font-family font-variant font-style font-weight letter-spacing word-spacing margin orientation position separation separation-along separation-all max-turn min-radius max-angle format categories optional sample line-height upcase shield curved]
TRANSFORMS =
%w[reduce fallback offset buffer smooth remove-holes minimum-area minimum-hole minimum-length remove keep-largest trim]
DEFAULTS =
YAML.load <<~YAML
  dupe: outline
  stroke: none
  fill: black
  font-style: italic
  font-family: Arial, Helvetica, sans-serif
  font-size: 1.8
  line-height: 110%
  margin: 1.0
  max-turn: 60
  min-radius: 0
  max-angle: #{StraightSkeleton::DEFAULT_ROUNDING_ANGLE}
  sample: #{DEFAULT_SAMPLE}
  outline:
    stroke: white
    fill: none
    stroke-width: 0.25
    stroke-opacity: 0.75
    blur: 0.06
YAML
DEBUG_PARAMS =
YAML.load <<~YAML
  debug:
    dupe: ~
    fill: none
    opacity: 0.5
  debug feature:
    stroke: "#6600ff"
    stroke-width: 0.2
    symbol:
      circle:
        r: 0.3
        stroke: none
        fill: "#6600ff"
  debug candidate:
    stroke: magenta
    stroke-width: 0.2
YAML

Constants included from Vector

Vector::FONT_SCALED_ATTRIBUTES, Vector::MARGIN, Vector::SVG_ATTRIBUTES

Constants included from Log

NSWTopo::Log::FAILURE, NSWTopo::Log::NEUTRAL, NSWTopo::Log::SUCCESS, NSWTopo::Log::UPDATE

Instance Method Summary collapse

Methods included from Vector

#categorise, #create, #features, #filename, #labeling_features, #params_for, #render, #svg_path_data, #to_mm, #to_s

Methods included from Log

#log_abort, #log_neutral, #log_success, #log_update, #log_warn

Instance Method Details

#add(layer) ⇒ Object



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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/nswtopo/layer/labels.rb', line 78

def add(layer)
  category_params, base_params = layer.params.fetch("labels", {}).partition do |key, value|
    Hash === value
  end.map(&:to_h)
  collate = base_params.delete "collate"
  @params.store layer.name, base_params if base_params.any?
  category_params.each do |category, params|
    categories = Array(category).map do |category|
      [layer.name, category].join(?\s)
    end
    @params.store categories, params
  end

  feature_count = feature_total = 0
  layer.labeling_features.tap do |features|
    feature_total = features.length
  end.map(&:multi).group_by do |feature|
    Set[layer.name, *feature["category"]]
  end.each do |categories, features|
    transforms, attributes, point_attributes, line_attributes = [nil, nil, "point", "line"].map do |extra_category|
      categories | Set[*extra_category]
    end.map do |categories|
      params_for(categories).merge("categories" => categories)
    end.zip([TRANSFORMS, PROPERTIES, PROPERTIES, PROPERTIES]).map do |selected_params, keys|
      selected_params.slice *keys
    end

    features.map do |feature|
      log_update "collecting labels: %s: feature %i of %i" % [layer.name, feature_count += 1, feature_total]
      label = feature["label"]
      text = case
      when REXML::Element === label then label
      when attributes["format"] then attributes["format"] % label
      else Array(label).map(&:to_s).map(&:strip).join(?\s)
      end
      text.upcase! if String === text && attributes["upcase"]

      transforms.inject([feature]) do |features, (transform, (arg, *args))|
        next features unless arg
        opts, args = args.partition do |arg|
          Hash === arg
        end
        opts = opts.inject({}, &:merge).transform_keys(&:to_sym)
        features.flat_map do |feature|
          case transform
          when "reduce"
            case arg
            when "skeleton"
              feature.respond_to?(arg) ? feature.send(arg) : feature
            when "centrelines"
              feature.respond_to?(arg) ? feature.send(arg, **opts) : feature
            when "centrepoints"
              interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
              feature.respond_to?(arg) ? feature.send(arg, interval: interval, **opts) : feature
            when "centres"
              interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
              feature.respond_to?(arg) ? feature.send(arg, interval: interval, **opts) : feature
            when "centroids"
              feature.respond_to?(arg) ? feature.send(arg) : feature
            when "samples"
              interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
              feature.respond_to?(arg) ? feature.send(arg, interval) : feature
            else
              raise "unrecognised label transform: reduce: %s" % arg
            end

          when "fallback"
            case arg
            when "samples"
              next feature unless feature.respond_to? arg
              interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
              [feature, *feature.send(arg, interval)]
            else
              raise "unrecognised label transform: fallback: %s" % arg
            end

          when "offset", "buffer"
            next feature unless feature.respond_to? transform
            margins = [arg, *args].map { |value| Float(value) * @map.scale / 1000.0 }
            feature.send transform, *margins, **opts

          when "smooth"
            next feature unless feature.respond_to? transform
            margin = Float(arg) * @map.scale / 1000.0
            max_turn = attributes["max-turn"] * Math::PI / 180
            feature.send transform, margin, cutoff_angle: max_turn, **opts

          when "minimum-area"
            area = Float(arg) * (@map.scale / 1000.0)**2
            case feature
            when GeoJSON::MultiLineString
              feature.coordinates = feature.coordinates.reject do |linestring|
                linestring.first == linestring.last && linestring.signed_area.abs < area
              end
            when GeoJSON::MultiPolygon
              feature.coordinates = feature.coordinates.reject do |rings|
                rings.sum(&:signed_area) < area
              end
            end
            feature.empty? ? [] : feature

          when "minimum-length"
            next feature unless GeoJSON::MultiLineString === feature
            distance = Float(arg) * @map.scale / 1000.0
            feature.coordinates = feature.coordinates.reject do |linestring|
              linestring.path_length < distance
            end
            feature.empty? ? [] : feature

          when "minimum-hole", "remove-holes"
            area = Float(arg).abs * @map.scale / 1000.0 unless true == arg
            feature.coordinates = feature.coordinates.map do |rings|
              rings.reject do |ring|
                area ? (-area...0) === ring.signed_area : ring.signed_area < 0
              end
            end if GeoJSON::MultiPolygon === feature
            feature

          when "remove"
            remove = [arg, *args].any? do |value|
              case value
              when true    then true
              when String  then text == value
              when Regexp  then text =~ value
              when Numeric then text == value.to_s
              end
            end
            remove ? [] : feature

          when "keep-largest"
            case feature
            when GeoJSON::MultiLineString
              feature.coordinates = [feature.explode.max_by(&:length).coordinates]
            when GeoJSON::MultiPolygon
              feature.coordinates = [feature.explode.max_by(&:area).coordinates]
            end
            feature

          when "trim"
            next feature unless GeoJSON::MultiLineString === feature
            distance = Float(arg) * @map.scale / 1000.0
            feature.coordinates = feature.coordinates.map do |linestring|
              linestring.trim distance
            end.reject(&:empty?)
            feature.empty? ? [] : feature
          end
        end
      rescue ArgumentError
        raise "invalid label transform: %s: %s" % [transform, [arg, *args].join(?,)]
      end.each do |feature|
        feature.properties = case feature
        when GeoJSON::MultiPoint      then point_attributes
        when GeoJSON::MultiLineString then line_attributes
        when GeoJSON::MultiPolygon    then line_attributes
        end
      end.yield_self do |features|
        GeoJSON::Collection.new(@map.projection, features).explode.extend(LabelFeatures)
      end.tap do |collection|
        collection.text, collection.layer_name = text, layer.name
      end
    end.yield_self do |collections|
      next collections unless collate
      collections.group_by(&:text).map do |text, collections|
        collections.inject(&:merge!)
      end
    end.each do |collection|
      label_features << collection
    end
  end
end

#add_fence(feature, buffer) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/nswtopo/layer/labels.rb', line 56

def add_fence(feature, buffer)
  index = fences.length
  case feature
  when GeoJSON::Point
    [[feature.coordinates.yield_self(&to_mm)] * 2]
  when GeoJSON::LineString
    feature.coordinates.map(&to_mm).segments
  when GeoJSON::Polygon
    feature.coordinates.flat_map { |ring| ring.map(&to_mm).segments }
  end.each do |segment|
    fences << Fence.new(segment, buffer: buffer, index: index)
  end
end

#drawing_featuresObject



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
327
328
329
330
331
332
333
334
335
336
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
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
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
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
602
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
# File 'lib/nswtopo/layer/labels.rb', line 275

def drawing_features
  fence_index = RTree.load(fences, &:bounds)
  labelling_hull = @map.bounding_box(mm: -INSET).coordinates.first.map(&to_mm)
  debug, debug_features = Config["debug"], []
  @params = DEBUG_PARAMS.deep_merge @params if debug

  candidates = label_features.map.with_index do |collection, label_index|
    log_update "compositing %s: feature %i of %i" % [@name, label_index + 1, label_features.length]
    collection.flat_map do |feature|
      case feature
      when GeoJSON::Point, GeoJSON::LineString
        feature
      when GeoJSON::Polygon
        feature.coordinates.map do |ring|
          GeoJSON::LineString.new ring, feature.properties
        end
      end
    end.map.with_index do |feature, feature_index|
      attributes = feature.properties
      font_size = attributes["font-size"]
      attributes.slice(*FONT_SCALED_ATTRIBUTES).each do |key, value|
        attributes[key] = value.to_i * font_size * 0.01 if value =~ /^\d+%$/
      end

      debug_features << [feature, Set["debug", "feature"]] if debug
      next [] if debug == "features"

      case feature
      when GeoJSON::Point
        margin, line_height = attributes.values_at "margin", "line-height"
        point = feature.coordinates.yield_self(&to_mm)
        lines = Font.in_two collection.text, attributes
        lines = [[collection.text, Font.glyph_length(collection.text, attributes)]] if lines.map(&:first).map(&:length).min == 1
        width = lines.map(&:last).max
        height = lines.map { font_size }.inject { |total| total + line_height }
        if attributes["shield"]
          width += SHIELD_X * font_size
          height += SHIELD_Y * font_size
        end
        [*attributes["position"] || "over"].map.with_index do |position, position_index|
          dx = position =~ /right$/ ? 1 : position =~ /left$/  ? -1 : 0
          dy = position =~ /^below/ ? 1 : position =~ /^above/ ? -1 : 0
          f = dx * dy == 0 ? 1 : 0.707
          origin = [dx, dy].times(f * margin).plus(point)

          text_elements = lines.map.with_index do |(line, text_length), index|
            y = (lines.one? ? 0 : dy == 0 ? index - 0.5 : index + 0.5 * (dy - 1)) * line_height
            y += (CENTRELINE_FRACTION + 0.5 * dy) * font_size
            REXML::Element.new("text").tap do |text|
              text.add_attribute "transform", "translate(%s)" % POINT % origin
              text.add_attribute "text-anchor", dx > 0 ? "start" : dx < 0 ? "end" : "middle"
              text.add_attribute "textLength", VALUE % text_length
              text.add_attribute "y", VALUE % y
              text.add_text line
            end
          end

          hull = [[dx, width], [dy, height]].map do |d, l|
            [d * f * margin + (d - 1) * 0.5 * l, d * f * margin + (d + 1) * 0.5 * l]
          end.inject(&:product).values_at(0,2,3,1).map do |corner|
            corner.plus point
          end
          next unless labelling_hull.surrounds? hull

          fence_count = fence_index.search(hull.transpose.map(&:minmax)).inject(Set[]) do |indices, fence|
            next indices if indices === fence.index
            fence.conflicts_with?(hull) ? indices << fence.index : indices
          end.size
          priority = [fence_count, position_index, feature_index]
          Label.new collection.layer_name, label_index, feature_index, priority, hull, attributes, text_elements
        end.compact.tap do |candidates|
          candidates.combination(2).each do |candidate1, candidate2|
            candidate1.conflicts << candidate2
            candidate2.conflicts << candidate1
          end
        end
      when GeoJSON::LineString
        closed = feature.coordinates.first == feature.coordinates.last
        pairs = closed ? :ring : :segments
        data = feature.coordinates.map(&to_mm)

        orientation = attributes["orientation"]
        max_turn    = attributes["max-turn"] * Math::PI / 180
        min_radius  = attributes["min-radius"]
        max_angle   = attributes["max-angle"] * Math::PI / 180
        curved      = attributes["curved"]
        sample      = attributes["sample"]
        separation  = attributes["separation-along"]

        text_length = case collection.text
        when REXML::Element then data.path_length
        when String then Font.glyph_length collection.text, attributes
        end

        points = data.segments.inject([]) do |memo, segment|
          distance = segment.distance
          case
          when REXML::Element === collection.text
            memo << segment[0]
          when curved && distance >= text_length
            memo << segment[0]
          else
            steps = (distance / sample).ceil
            memo += steps.times.map do |step|
              segment.along(step.to_f / steps)
            end
          end
        end
        points << data.last unless closed

        segments = points.send(pairs)
        vectors = segments.map(&:difference)
        distances = vectors.map(&:norm)

        cumulative = distances.inject([0]) do |memo, distance|
          memo << memo.last + distance
        end
        total = closed ? cumulative.pop : cumulative.last

        angles = vectors.map(&:normalised).send(pairs).map do |directions|
          Math.atan2 directions.inject(&:cross), directions.inject(&:dot)
        end
        closed ? angles.rotate!(-1) : angles.unshift(0).push(0)

        curvatures = segments.send(pairs).map do |(p0, p1), (_, p2)|
          sides = [[p0, p1], [p1, p2], [p2, p0]].map(&:distance)
          semiperimeter = 0.5 * sides.inject(&:+)
          diffs = sides.map { |side| semiperimeter - side }
          area_squared = [semiperimeter * diffs.inject(&:*), 0].max
          4 * Math::sqrt(area_squared) / sides.inject(&:*)
        end
        closed ? curvatures.rotate!(-1) : curvatures.unshift(0).push(0)

        dont_use = angles.zip(curvatures).map do |angle, curvature|
          angle.abs > max_angle || min_radius * curvature > 1
        end

        squared_angles = angles.map { |angle| angle * angle }

        overlaps = Hash.new do |hash, segment|
          bounds = segment.transpose.map(&:minmax).map do |min, max|
            [min - 0.5 * font_size, max + 0.5 * font_size]
          end
          hash[segment] = fence_index.search(bounds).any? do |fence|
            fence.conflicts_with? segment, 0.5 * font_size
          end
        end

        Enumerator.new do |yielder|
          indices, distance, bad_indices, angle_integral = [0], 0, [], []
          loop do
            while distance < text_length
              break true if closed ? indices.many? && indices.last == indices.first : indices.last == points.length - 1
              unless indices.one?
                bad_indices << dont_use[indices.last]
                angle_integral << (angle_integral.last || 0) + angles[indices.last]
              end
              distance += distances[indices.last]
              indices << (indices.last + 1) % points.length
            end && break

            while distance >= text_length
              case
              when indices.length == 2 && curved
              when indices.length == 2 then yielder << indices.dup
              when distance - distances[indices.first] >= text_length
              when bad_indices.any?
              when angle_integral.max - angle_integral.min > max_turn
              else yielder << indices.dup
              end
              angle_integral.shift
              bad_indices.shift
              distance -= distances[indices.first]
              indices.shift
              break true if indices.first == (closed ? 0 : points.length - 1)
            end && break
          end if points.many?
        end.map do |indices|
          start, stop = cumulative.values_at(*indices)
          along = (start + 0.5 * (stop - start) % total) % total
          total_squared_curvature = squared_angles.values_at(*indices[1...-1]).inject(0, &:+)
          baseline = points.values_at(*indices).crop(text_length)

          fence = baseline.segments.any? do |segment|
            overlaps[segment]
          end
          priority = [fence ? 1 : 0, total_squared_curvature, (total - 2 * along).abs / total.to_f]

          case
          when "uphill" == orientation
          when "downhill" == orientation then baseline.reverse!
          when baseline.values_at(0, -1).map(&:first).inject(&:<=)
          else baseline.reverse!
          end

          hull = GeoJSON::LineString.new(baseline).multi.buffer(0.5 * font_size, splits: false).coordinates.flatten(1).convex_hull
          next unless labelling_hull.surrounds? hull

          path_id = [@name, collection.layer_name, "path", label_index, feature_index, indices.first, indices.last].join ?.
          path_element = REXML::Element.new("path")
          path_element.add_attributes "id" => path_id, "d" => svg_path_data(baseline), "pathLength" => VALUE % text_length
          text_element = REXML::Element.new("text")

          case collection.text
          when REXML::Element
            text_element.add_element collection.text, "xlink:href" => "#%s" % path_id
          when String
            text_path = text_element.add_element "textPath", "xlink:href" => "#%s" % path_id, "textLength" => VALUE % text_length, "spacing" => "auto"
            text_path.add_element("tspan", "dy" => VALUE % (CENTRELINE_FRACTION * font_size)).add_text(collection.text)
          end
          Label.new collection.layer_name, label_index, feature_index, priority, hull, attributes, [text_element, path_element], along
        end.compact.map do |candidate|
          [candidate, []]
        end.to_h.tap do |matrix|
          matrix.keys.nearby_pairs(closed) do |pair|
            diff = pair.map(&:along).inject(&:-)
            2 * (closed ? [diff % total, -diff % total].min : diff.abs) < sample
          end.each do |pair|
            matrix[pair[0]] << pair[1]
            matrix[pair[1]] << pair[0]
          end
        end.sort_by do |candidate, nearby|
          candidate.priority
        end.to_h.tap do |matrix|
          matrix.each do |candidate, nearby|
            nearby.each do |candidate|
              matrix.delete candidate
            end
          end
        end.keys.tap do |candidates|
          candidates.sort_by(&:along).inject do |(*candidates), candidate2|
            while candidates.any?
              break if (candidate2.along - candidates.first.along) % total < separation + text_length
              candidates.shift
            end
            candidates.each do |candidate1|
              candidate1.conflicts << candidate2
              candidate2.conflicts << candidate1
            end.push(candidate2)
          end if separation
        end
      end
    end.flatten.tap do |candidates|
      candidates.reject!(&:point?) unless candidates.all?(&:point?)
    end.sort_by(&:priority).each.with_index do |candidate, index|
      candidate.priority = index
    end
  end.flatten

  candidates.each do |candidate|
    debug_features << [candidate.hull, Set["debug", "candidate"]]
  end if debug
  return debug_features if %w[features candidates].include? debug

  candidates.map(&:hull).overlaps.map do |indices|
    candidates.values_at *indices
  end.each do |candidate1, candidate2|
    candidate1.conflicts << candidate2
    candidate2.conflicts << candidate1
  end

  candidates.group_by do |candidate|
    [candidate.label_index, candidate.attributes["separation"]]
  end.each do |(label_index, buffer), candidates|
    candidates.map(&:hull).overlaps(buffer).map do |indices|
      candidates.values_at *indices
    end.each do |candidate1, candidate2|
      candidate1.conflicts << candidate2
      candidate2.conflicts << candidate1
    end if buffer
  end

  candidates.group_by do |candidate|
    [candidate.layer_name, candidate.attributes["separation-all"]]
  end.each do |(layer_name, buffer), candidates|
    candidates.map(&:hull).overlaps(buffer).map do |indices|
      candidates.values_at *indices
    end.each do |candidate1, candidate2|
      candidate1.conflicts << candidate2
      candidate2.conflicts << candidate1
    end if buffer
  end

  conflicts = candidates.map do |candidate|
    [candidate, candidate.conflicts.dup]
  end.to_h
  labels, remaining, changed = Set.new, AVLTree.new, candidates
  grouped = candidates.to_set.classify(&:label_index)
  counts = Hash.new { |hash, label_index| hash[label_index] = 0 }

  loop do
    changed.each do |candidate|
      conflict_count = conflicts[candidate].count do |other|
        other.label_index != candidate.label_index
      end
      labelled = counts[candidate.label_index].zero? ? 0 : 1
      optional = candidate.optional? ? 1 : 0
      grid = candidate.layer_name == "grid" ? 0 : 1
      ordinal = [grid, optional, conflict_count, labelled, candidate.priority]
      next if candidate.ordinal == ordinal
      remaining.delete candidate
      candidate.ordinal = ordinal
      remaining.insert candidate
    end
    break unless label = remaining.first
    labels << label
    counts[label.label_index] += 1
    removals = Set[label] | conflicts[label]
    removals.each do |candidate|
      grouped[candidate.label_index].delete candidate
      remaining.delete candidate
    end
    changed = conflicts.values_at(*removals).inject(Set[], &:|).subtract(removals).each do |candidate|
      conflicts[candidate].subtract removals
    end
    changed.merge grouped[label.label_index] if counts[label.label_index] == 1
  end

  candidates.reject(&:optional?).group_by(&:label_index).select do |label_index, candidates|
    counts[label_index].zero?
  end.each do |label_index, candidates|
    label = candidates.min_by do |candidate|
      [(candidate.conflicts & labels).length, candidate.priority]
    end
    label.conflicts.intersection(labels).each do |other|
      next unless counts[other.label_index] > 1
      labels.delete other
      counts[other.label_index] -= 1
    end
    labels << label
    counts[label_index] += 1
  end if Config["allow-overlaps"]

  grouped = candidates.group_by do |candidate|
    [candidate.label_index, candidate.feature_index]
  end
  5.times do
    labels = labels.inject(labels.dup) do |labels, label|
      next labels unless label.point?
      labels.delete label
      labels << grouped[[label.label_index, label.feature_index]].min_by do |candidate|
        [(labels & candidate.conflicts - Set[label]).count, candidate.priority]
      end
    end
  end

  labels.map do |label|
    label.elements.map do |element|
      [element, label.categories]
    end
  end.flatten(1).tap do |result|
    result.concat debug_features if debug
  end
end

#fencesObject



52
53
54
# File 'lib/nswtopo/layer/labels.rb', line 52

def fences
  @fences ||= []
end

#label_featuresObject



70
71
72
# File 'lib/nswtopo/layer/labels.rb', line 70

def label_features
  @label_features ||= []
end