nswtopo 2.0.0.pre.beta1 → 3.0

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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/COPYING +70 -83
  3. data/bin/nswtopo +227 -116
  4. data/docs/README.md +1 -12
  5. data/docs/add.md +1 -1
  6. data/docs/config.md +1 -1
  7. data/docs/contours.md +3 -1
  8. data/docs/init.md +8 -0
  9. data/docs/inspect.md +103 -0
  10. data/docs/move.md +9 -0
  11. data/docs/render.md +16 -7
  12. data/docs/scrape.md +67 -0
  13. data/docs/spot-heights.md +6 -2
  14. data/lib/nswtopo/archive.rb +50 -41
  15. data/lib/nswtopo/chrome.rb +227 -0
  16. data/lib/nswtopo/commands/add.rb +106 -0
  17. data/lib/nswtopo/commands/config.rb +38 -0
  18. data/lib/nswtopo/commands/inspect.rb +74 -0
  19. data/lib/nswtopo/commands/layers.rb +22 -0
  20. data/lib/nswtopo/commands/scrape.rb +79 -0
  21. data/lib/nswtopo/commands.rb +57 -0
  22. data/lib/nswtopo/dither.rb +5 -3
  23. data/lib/nswtopo/font.rb +46 -21
  24. data/lib/nswtopo/formats/gemf.rb +42 -0
  25. data/lib/nswtopo/formats/kmz.rb +26 -24
  26. data/lib/nswtopo/formats/mbtiles.rb +5 -41
  27. data/lib/nswtopo/formats/pdf.rb +82 -17
  28. data/lib/nswtopo/formats/svg.rb +114 -45
  29. data/lib/nswtopo/formats/svgz.rb +2 -2
  30. data/lib/nswtopo/formats/zip.rb +33 -23
  31. data/lib/nswtopo/formats.rb +77 -32
  32. data/lib/nswtopo/geometry/overlap.rb +1 -32
  33. data/lib/nswtopo/geometry/r_tree.rb +16 -10
  34. data/lib/nswtopo/geometry/segment.rb +3 -3
  35. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +5 -6
  36. data/lib/nswtopo/geometry/vector_sequence.rb +7 -6
  37. data/lib/nswtopo/gis/arcgis/connection.rb +56 -0
  38. data/lib/nswtopo/gis/arcgis/layer/map.rb +163 -0
  39. data/lib/nswtopo/gis/arcgis/layer/query.rb +87 -0
  40. data/lib/nswtopo/gis/arcgis/layer/renderer.rb +66 -0
  41. data/lib/nswtopo/gis/arcgis/layer/statistics.rb +15 -0
  42. data/lib/nswtopo/gis/arcgis/layer.rb +201 -0
  43. data/lib/nswtopo/gis/arcgis/service.rb +57 -0
  44. data/lib/nswtopo/gis/arcgis.rb +3 -0
  45. data/lib/nswtopo/gis/dem.rb +13 -12
  46. data/lib/nswtopo/gis/esri_hdr.rb +8 -2
  47. data/lib/nswtopo/gis/geojson/collection.rb +45 -21
  48. data/lib/nswtopo/gis/geojson/multi_line_string.rb +2 -24
  49. data/lib/nswtopo/gis/geojson/multi_polygon.rb +2 -53
  50. data/lib/nswtopo/gis/geojson/polygon.rb +15 -0
  51. data/lib/nswtopo/gis/geojson.rb +12 -3
  52. data/lib/nswtopo/gis/gps/kml.rb +25 -19
  53. data/lib/nswtopo/gis/gps.rb +2 -0
  54. data/lib/nswtopo/gis/projection.rb +35 -24
  55. data/lib/nswtopo/gis/shapefile.rb +89 -16
  56. data/lib/nswtopo/gis.rb +1 -2
  57. data/lib/nswtopo/helpers/array.rb +0 -11
  58. data/lib/nswtopo/helpers/colour.rb +34 -14
  59. data/lib/nswtopo/layer/arcgis_raster.rb +44 -48
  60. data/lib/nswtopo/layer/colour_mask.rb +5 -0
  61. data/lib/nswtopo/layer/contour.rb +35 -28
  62. data/lib/nswtopo/layer/control.rb +2 -7
  63. data/lib/nswtopo/layer/declination.rb +9 -9
  64. data/lib/nswtopo/layer/feature.rb +36 -22
  65. data/lib/nswtopo/layer/grid.rb +30 -27
  66. data/lib/nswtopo/layer/import.rb +1 -21
  67. data/lib/nswtopo/layer/labels/barrier.rb +39 -0
  68. data/lib/nswtopo/layer/labels.rb +551 -383
  69. data/lib/nswtopo/layer/mask_render.rb +37 -0
  70. data/lib/nswtopo/layer/overlay.rb +2 -2
  71. data/lib/nswtopo/layer/raster.rb +31 -41
  72. data/lib/nswtopo/layer/raster_import.rb +17 -0
  73. data/lib/nswtopo/layer/raster_render.rb +15 -0
  74. data/lib/nswtopo/layer/relief.rb +27 -95
  75. data/lib/nswtopo/layer/spot.rb +63 -62
  76. data/lib/nswtopo/layer/vector/cutout.rb +15 -0
  77. data/lib/nswtopo/layer/vector/knockout.rb +16 -0
  78. data/lib/nswtopo/layer/vector.rb +121 -89
  79. data/lib/nswtopo/layer/vegetation.rb +39 -34
  80. data/lib/nswtopo/layer.rb +30 -16
  81. data/lib/nswtopo/map.rb +202 -109
  82. data/lib/nswtopo/os.rb +5 -27
  83. data/lib/nswtopo/tiled_web_map.rb +54 -0
  84. data/lib/nswtopo/tree_indenter.rb +27 -0
  85. data/lib/nswtopo/version.rb +27 -2
  86. data/lib/nswtopo.rb +6 -199
  87. metadata +41 -22
  88. data/lib/nswtopo/font/chrome.rb +0 -59
  89. data/lib/nswtopo/font/generic.rb +0 -25
  90. data/lib/nswtopo/gis/arcgis_server/connection.rb +0 -52
  91. data/lib/nswtopo/gis/arcgis_server.rb +0 -155
  92. data/lib/nswtopo/gis/geojson/multi_point.rb +0 -12
  93. data/lib/nswtopo/gis/world_file.rb +0 -19
  94. data/lib/nswtopo/layer/labels/fence.rb +0 -20
@@ -1,4 +1,8 @@
1
- require_relative 'labels/fence'
1
+ # Based on:
2
+ # Fast Point-Feature Label Placement Algorithm for Real Time Screen Maps
3
+ # (Missae Yamamoto, Gilberto Camara, Luiz Antonio Nogueira Lorena)
4
+
5
+ require_relative 'labels/barrier'
2
6
 
3
7
  module NSWTopo
4
8
  module Labels
@@ -7,11 +11,54 @@ module NSWTopo
7
11
  DEFAULT_SAMPLE = 5
8
12
  INSET = 1
9
13
 
10
- 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]
11
- TRANSFORMS = %w[reduce fallback offset buffer smooth remove-holes minimum-area minimum-hole minimum-length remove keep-largest trim]
14
+ LABEL_ATTRIBUTES = %w[
15
+ coexist
16
+ curved
17
+ font-family
18
+ font-size
19
+ font-style
20
+ font-variant
21
+ font-weight
22
+ format
23
+ knockout
24
+ letter-spacing
25
+ line-height
26
+ margin
27
+ max-angle
28
+ max-turn
29
+ min-radius
30
+ optional
31
+ orientation
32
+ position
33
+ sample
34
+ separation
35
+ separation-all
36
+ separation-along
37
+ shield
38
+ upcase
39
+ word-spacing
40
+ ]
41
+
42
+ LABEL_TRANSFORMS = %w[
43
+ buffer
44
+ fallback
45
+ keep-largest
46
+ minimum-area
47
+ minimum-hole
48
+ minimum-length
49
+ offset
50
+ reduce
51
+ remove
52
+ remove-holes
53
+ smooth
54
+ trim
55
+ ]
56
+
57
+ LABEL_PARAMS = LABEL_ATTRIBUTES + LABEL_TRANSFORMS + SVG_ATTRIBUTES
12
58
 
13
59
  DEFAULTS = YAML.load <<~YAML
14
- dupe: outline
60
+ knockout: true
61
+ preserve: true
15
62
  stroke: none
16
63
  fill: black
17
64
  font-style: italic
@@ -23,12 +70,6 @@ module NSWTopo
23
70
  min-radius: 0
24
71
  max-angle: #{StraightSkeleton::DEFAULT_ROUNDING_ANGLE}
25
72
  sample: #{DEFAULT_SAMPLE}
26
- outline:
27
- stroke: white
28
- fill: none
29
- stroke-width: 0.25
30
- stroke-opacity: 0.75
31
- blur: 0.06
32
73
  YAML
33
74
 
34
75
  DEBUG_PARAMS = YAML.load <<~YAML
@@ -36,35 +77,22 @@ module NSWTopo
36
77
  dupe: ~
37
78
  fill: none
38
79
  opacity: 0.5
80
+ knockout: false
39
81
  debug feature:
40
- stroke: "#6600ff"
82
+ stroke: hsl(260,100%,50%)
41
83
  stroke-width: 0.2
42
84
  symbol:
43
85
  circle:
44
86
  r: 0.3
45
87
  stroke: none
46
- fill: "#6600ff"
88
+ fill: hsl(260,100%,50%)
47
89
  debug candidate:
48
- stroke: magenta
90
+ stroke: hsl(300,100%,50%)
49
91
  stroke-width: 0.2
50
92
  YAML
51
93
 
52
- def fences
53
- @fences ||= []
54
- end
55
-
56
- def add_fence(feature, buffer)
57
- index = fences.length
58
- case feature
59
- when GeoJSON::Point
60
- [[feature.coordinates.yield_self(&to_mm)] * 2]
61
- when GeoJSON::LineString
62
- feature.coordinates.map(&to_mm).segments
63
- when GeoJSON::Polygon
64
- feature.coordinates.flat_map { |ring| ring.map(&to_mm).segments }
65
- end.each do |segment|
66
- fences << Fence.new(segment, buffer: buffer, index: index)
67
- end
94
+ def barriers
95
+ @barriers ||= []
68
96
  end
69
97
 
70
98
  def label_features
@@ -72,20 +100,37 @@ module NSWTopo
72
100
  end
73
101
 
74
102
  module LabelFeatures
75
- attr_accessor :text, :layer_name
103
+ attr_accessor :text, :dual, :layer_name
76
104
  end
77
105
 
106
+ extend Forwardable
107
+ delegate :<< => :barriers
108
+
78
109
  def add(layer)
79
- category_params, base_params = layer.params.fetch("labels", {}).partition do |key, value|
110
+ label_params = layer.params.fetch("labels", {})
111
+ label_params.except(*LABEL_PARAMS).select do |key, value|
80
112
  Hash === value
81
- end.map(&:to_h)
82
- collate = base_params.delete "collate"
83
- @params.store layer.name, base_params if base_params.any?
84
- category_params.each do |category, params|
85
- categories = Array(category).map do |category|
113
+ end.transform_keys do |categories|
114
+ Array(categories).map do |category|
86
115
  [layer.name, category].join(?\s)
87
116
  end
88
- @params.store categories, params
117
+ end.then do |params|
118
+ { layer.name => label_params }.merge(params)
119
+ end.transform_values do |params|
120
+ params.slice(*LABEL_PARAMS)
121
+ end.transform_values do |params|
122
+ # handle legacy format for separation, separation-all, separation-along
123
+ params.each.with_object("separation" => Hash[]) do |(key, value), hash|
124
+ case [key, value]
125
+ in ["separation", Hash] then hash["separation"].merge! value
126
+ in ["separation", *] then hash["separation"].merge! "self" => value
127
+ in ["separation-all", *] then hash["separation"].merge! "other" => value
128
+ in ["separation-along", *] then hash["separation"].merge! "along" => value
129
+ else hash[key] = value
130
+ end
131
+ end
132
+ end.then do |category_params|
133
+ @params.merge! category_params
89
134
  end
90
135
 
91
136
  feature_count = feature_total = 0
@@ -94,23 +139,24 @@ module NSWTopo
94
139
  end.map(&:multi).group_by do |feature|
95
140
  Set[layer.name, *feature["category"]]
96
141
  end.each do |categories, features|
97
- transforms, attributes, point_attributes, line_attributes = [nil, nil, "point", "line"].map do |extra_category|
142
+ transforms = params_for(categories).slice(*LABEL_TRANSFORMS)
143
+ attributes, point_attributes, line_attributes = [nil, "point", "line"].map do |extra_category|
98
144
  categories | Set[*extra_category]
99
145
  end.map do |categories|
100
- params_for(categories).merge("categories" => categories)
101
- end.zip([TRANSFORMS, PROPERTIES, PROPERTIES, PROPERTIES]).map do |selected_params, keys|
102
- selected_params.slice *keys
146
+ params_for(categories).slice(*LABEL_ATTRIBUTES).merge("categories" => categories)
103
147
  end
104
148
 
105
149
  features.map do |feature|
106
150
  log_update "collecting labels: %s: feature %i of %i" % [layer.name, feature_count += 1, feature_total]
107
- label = feature["label"]
151
+ text = feature["label"]
108
152
  text = case
109
- when REXML::Element === label then label
110
- when attributes["format"] then attributes["format"] % label
111
- else Array(label).map(&:to_s).map(&:strip).join(?\s)
153
+ when REXML::Element === text then text
154
+ when attributes["format"] then attributes["format"] % text
155
+ else Array(text).map(&:to_s).map(&:strip).join(?\s)
112
156
  end
157
+ dual = feature["dual"]
113
158
  text.upcase! if String === text && attributes["upcase"]
159
+ dual.upcase! if String === dual && attributes["upcase"]
114
160
 
115
161
  transforms.inject([feature]) do |features, (transform, (arg, *args))|
116
162
  next features unless arg
@@ -127,15 +173,15 @@ module NSWTopo
127
173
  when "centrelines"
128
174
  feature.respond_to?(arg) ? feature.send(arg, **opts) : feature
129
175
  when "centrepoints"
130
- interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
176
+ interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
131
177
  feature.respond_to?(arg) ? feature.send(arg, interval: interval, **opts) : feature
132
178
  when "centres"
133
- interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
179
+ interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
134
180
  feature.respond_to?(arg) ? feature.send(arg, interval: interval, **opts) : feature
135
181
  when "centroids"
136
182
  feature.respond_to?(arg) ? feature.send(arg) : feature
137
183
  when "samples"
138
- interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
184
+ interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
139
185
  feature.respond_to?(arg) ? feature.send(arg, interval) : feature
140
186
  else
141
187
  raise "unrecognised label transform: reduce: %s" % arg
@@ -145,7 +191,7 @@ module NSWTopo
145
191
  case arg
146
192
  when "samples"
147
193
  next feature unless feature.respond_to? arg
148
- interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE) * @map.scale / 1000.0
194
+ interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
149
195
  [feature, *feature.send(arg, interval)]
150
196
  else
151
197
  raise "unrecognised label transform: fallback: %s" % arg
@@ -153,17 +199,17 @@ module NSWTopo
153
199
 
154
200
  when "offset", "buffer"
155
201
  next feature unless feature.respond_to? transform
156
- margins = [arg, *args].map { |value| Float(value) * @map.scale / 1000.0 }
202
+ margins = [arg, *args].map { |value| Float(value) }
157
203
  feature.send transform, *margins, **opts
158
204
 
159
205
  when "smooth"
160
206
  next feature unless feature.respond_to? transform
161
- margin = Float(arg) * @map.scale / 1000.0
207
+ margin = Float(arg)
162
208
  max_turn = attributes["max-turn"] * Math::PI / 180
163
209
  feature.send transform, margin, cutoff_angle: max_turn, **opts
164
210
 
165
211
  when "minimum-area"
166
- area = Float(arg) * (@map.scale / 1000.0)**2
212
+ area = Float(arg)
167
213
  case feature
168
214
  when GeoJSON::MultiLineString
169
215
  feature.coordinates = feature.coordinates.reject do |linestring|
@@ -178,14 +224,14 @@ module NSWTopo
178
224
 
179
225
  when "minimum-length"
180
226
  next feature unless GeoJSON::MultiLineString === feature
181
- distance = Float(arg) * @map.scale / 1000.0
227
+ distance = Float(arg)
182
228
  feature.coordinates = feature.coordinates.reject do |linestring|
183
229
  linestring.path_length < distance
184
230
  end
185
231
  feature.empty? ? [] : feature
186
232
 
187
233
  when "minimum-hole", "remove-holes"
188
- area = Float(arg).abs * @map.scale / 1000.0 unless true == arg
234
+ area = Float(arg).abs unless true == arg
189
235
  feature.coordinates = feature.coordinates.map do |rings|
190
236
  rings.reject do |ring|
191
237
  area ? (-area...0) === ring.signed_area : ring.signed_area < 0
@@ -215,7 +261,7 @@ module NSWTopo
215
261
 
216
262
  when "trim"
217
263
  next feature unless GeoJSON::MultiLineString === feature
218
- distance = Float(arg) * @map.scale / 1000.0
264
+ distance = Float(arg)
219
265
  feature.coordinates = feature.coordinates.map do |linestring|
220
266
  linestring.trim distance
221
267
  end.reject(&:empty?)
@@ -230,13 +276,13 @@ module NSWTopo
230
276
  when GeoJSON::MultiLineString then line_attributes
231
277
  when GeoJSON::MultiPolygon then line_attributes
232
278
  end
233
- end.yield_self do |features|
234
- GeoJSON::Collection.new(@map.projection, features).explode.extend(LabelFeatures)
279
+ end.then do |features|
280
+ GeoJSON::Collection.new(projection: @map.neatline.projection, features: features).explode.extend(LabelFeatures)
235
281
  end.tap do |collection|
236
- collection.text, collection.layer_name = text, layer.name
282
+ collection.text, collection.dual, collection.layer_name = text, dual, layer.name
237
283
  end
238
- end.yield_self do |collections|
239
- next collections unless collate
284
+ end.then do |collections|
285
+ next collections unless label_params["collate"]
240
286
  collections.group_by(&:text).map do |text, collections|
241
287
  collections.inject(&:merge!)
242
288
  end
@@ -246,384 +292,506 @@ module NSWTopo
246
292
  end
247
293
  end
248
294
 
249
- Label = Struct.new(:layer_name, :label_index, :feature_index, :priority, :hull, :attributes, :elements, :along) do
295
+ class Label
296
+ def initialize(collection, label_index, feature_index, barrier_count, priority, hulls, attributes, elements, along = nil, fixed = nil)
297
+ @label_index, @feature_index, @indices = label_index, feature_index, [label_index, feature_index]
298
+ @collection, @barrier_count, @priority, @hulls, @attributes, @elements, @along, @fixed = collection, barrier_count, priority, hulls, attributes, elements, along, fixed
299
+ @ordinal = [@barrier_count, @priority]
300
+ @conflicts = Set.new
301
+ end
302
+
303
+ extend Forwardable
304
+ delegate %i[text dual layer_name] => :@collection
305
+ delegate %i[[] dig] => :@attributes
306
+
307
+ attr_reader :label_index, :feature_index, :indices
308
+ attr_reader :barrier_count, :hulls, :elements, :along, :fixed, :conflicts
309
+ attr_accessor :priority, :ordinal
310
+
250
311
  def point?
251
- along.nil?
312
+ @along.nil?
252
313
  end
253
314
 
254
- def optional?
255
- attributes["optional"]
315
+ def barriers?
316
+ @barrier_count > 0
256
317
  end
257
318
 
258
- def categories
259
- attributes["categories"]
319
+ def optional?
320
+ @attributes["optional"]
260
321
  end
261
322
 
262
- def conflicts
263
- @conflicts ||= Set.new
323
+ def coexists_with?(other)
324
+ Array(@attributes["coexist"]).include? other.layer_name
264
325
  end
265
326
 
266
- attr_accessor :ordinal
267
327
  def <=>(other)
268
328
  self.ordinal <=> other.ordinal
269
329
  end
270
330
 
271
331
  alias hash object_id
272
332
  alias eql? equal?
333
+
334
+ def bounds
335
+ @hulls.flatten(1).transpose.map(&:minmax)
336
+ end
337
+
338
+ def self.overlaps?(label1, label2, buffer:)
339
+ return false if label1 == label2
340
+ [label1, label2].map(&:hulls).inject(&:product).any? do |hulls|
341
+ hulls.overlap?(buffer)
342
+ end
343
+ end
344
+
345
+ def self.overlaps(labels, &block)
346
+ Enumerator.new do |yielder|
347
+ next unless labels.any?(&block)
348
+ index = RTree.load(labels, &:bounds)
349
+ index.each do |bounds, label|
350
+ next unless buffer = yield(label)
351
+ index.search(bounds, buffer: buffer).with_object(label).select do |other, label|
352
+ overlaps? label, other, buffer: buffer
353
+ end.inject(yielder, &:<<)
354
+ end
355
+ end
356
+ end
273
357
  end
274
358
 
275
- def drawing_features
276
- fence_index = RTree.load(fences, &:bounds)
277
- labelling_hull = @map.bounding_box(mm: -INSET).coordinates.first.map(&to_mm)
278
- debug, debug_features = Config["debug"], []
279
- @params = DEBUG_PARAMS.deep_merge @params if debug
359
+ def labelling_hull
360
+ # TODO: doesn't account for map insets, need to replace with generalised check for non-covex @map.neatline
361
+ @labelling_hull ||= @map.neatline(mm: -INSET).coordinates.first.transpose.map(&:minmax).inject(&:product).values_at(0,2,3,1,0)
362
+ end
280
363
 
281
- candidates = label_features.map.with_index do |collection, label_index|
282
- log_update "compositing %s: feature %i of %i" % [@name, label_index + 1, label_features.length]
283
- collection.flat_map do |feature|
284
- case feature
285
- when GeoJSON::Point, GeoJSON::LineString
286
- feature
287
- when GeoJSON::Polygon
288
- feature.coordinates.map do |ring|
289
- GeoJSON::LineString.new ring, feature.properties
290
- end
364
+ def barrier_segments
365
+ @barrier_segments ||= barriers.flat_map(&:segments).then do |segments|
366
+ RTree.load(segments, &:bounds)
367
+ end
368
+ end
369
+
370
+ def point_candidates(collection, label_index, feature_index, feature)
371
+ attributes = feature.properties
372
+ margin = attributes["margin"]
373
+ line_height = attributes["line-height"]
374
+ font_size = attributes["font-size"]
375
+
376
+ point = feature.coordinates
377
+ lines = Font.in_two collection.text, attributes
378
+ lines = [[collection.text, Font.glyph_length(collection.text, attributes)]] if lines.map(&:first).map(&:length).min == 1
379
+ height = lines.map { font_size }.inject { |total| total + line_height }
380
+ # if attributes["shield"]
381
+ # width += SHIELD_X * font_size
382
+ # height += SHIELD_Y * font_size
383
+ # end
384
+
385
+ [*attributes["position"] || "over"].map do |position|
386
+ dx = position =~ /right$/ ? 1 : position =~ /left$/ ? -1 : 0
387
+ dy = position =~ /^below/ ? 1 : position =~ /^above/ ? -1 : 0
388
+ next dx, dy, dx * dy == 0 ? 1 : 0.6
389
+ end.uniq.map.with_index do |(dx, dy, f), position_index|
390
+ text_elements, hulls = lines.map.with_index do |(line, text_length), index|
391
+ anchor = point.dup
392
+ anchor[0] += dx * (f * margin + 0.5 * text_length)
393
+ anchor[1] += dy * (f * margin + 0.5 * height)
394
+ anchor[1] += (index - 0.5) * 0.5 * height unless lines.one?
395
+
396
+ text_element = REXML::Element.new("text")
397
+ text_element.add_attribute "transform", "translate(%s)" % POINT % anchor
398
+ text_element.add_attribute "text-anchor", "middle"
399
+ text_element.add_attribute "textLength", VALUE % text_length
400
+ text_element.add_attribute "y", VALUE % (CENTRELINE_FRACTION * font_size)
401
+ text_element.add_text line
402
+
403
+ hull = [text_length, font_size].zip(anchor).map do |size, origin|
404
+ [origin - 0.5 * size, origin + 0.5 * size]
405
+ end.inject(&:product).values_at(0,2,3,1)
406
+
407
+ next text_element, hull
408
+ end.transpose
409
+
410
+ next unless hulls.all? do |hull|
411
+ labelling_hull.surrounds? hull
412
+ end
413
+
414
+ bounds = hulls.flatten(1).transpose.map(&:minmax)
415
+ barrier_count = barrier_segments.search(bounds).with_object Set[] do |segment, barriers|
416
+ next if barriers === segment.barrier
417
+ hulls.any? do |hull|
418
+ barriers << segment.barrier if segment.conflicts_with? hull
291
419
  end
292
- end.map.with_index do |feature, feature_index|
293
- attributes = feature.properties
294
- font_size = attributes["font-size"]
295
- attributes.slice(*FONT_SCALED_ATTRIBUTES).each do |key, value|
296
- attributes[key] = value.to_i * font_size * 0.01 if value =~ /^\d+%$/
420
+ end.size
421
+ priority = [position_index, feature_index]
422
+ Label.new collection, label_index, feature_index, barrier_count, priority, hulls, attributes, text_elements
423
+ end.compact.reject do |candidate|
424
+ candidate.optional? && candidate.barriers?
425
+ end.tap do |candidates|
426
+ candidates.combination(2).each do |candidate1, candidate2|
427
+ candidate1.conflicts << candidate2
428
+ candidate2.conflicts << candidate1
429
+ end
430
+ end
431
+ end
432
+
433
+ def line_string_candidates(collection, label_index, feature_index, feature)
434
+ closed = feature.coordinates.first == feature.coordinates.last
435
+ pairs = closed ? :ring : :segments
436
+ data = feature.coordinates
437
+
438
+ attributes = feature.properties
439
+ orientation = attributes["orientation"]
440
+ max_turn = attributes["max-turn"] * Math::PI / 180
441
+ min_radius = attributes["min-radius"]
442
+ max_angle = attributes["max-angle"] * Math::PI / 180
443
+ curved = attributes["curved"]
444
+ sample = attributes["sample"]
445
+ font_size = attributes["font-size"]
446
+
447
+ text_length = case collection.text
448
+ when REXML::Element then data.path_length
449
+ when String then Font.glyph_length collection.text, attributes
450
+ end
451
+
452
+ points = data.segments.inject([]) do |memo, segment|
453
+ distance = segment.distance
454
+ case
455
+ when REXML::Element === collection.text
456
+ memo << segment[0]
457
+ when curved && distance >= text_length
458
+ memo << segment[0]
459
+ else
460
+ steps = (distance / sample).ceil
461
+ memo += steps.times.map do |step|
462
+ segment.along(step.to_f / steps)
297
463
  end
464
+ end
465
+ end
466
+ points << data.last unless closed
298
467
 
299
- debug_features << [feature, Set["debug", "feature"]] if debug
300
- next [] if debug == "features"
468
+ segments = points.send(pairs)
469
+ vectors = segments.map(&:diff)
470
+ distances = vectors.map(&:norm)
301
471
 
302
- case feature
303
- when GeoJSON::Point
304
- margin, line_height = attributes.values_at "margin", "line-height"
305
- point = feature.coordinates.yield_self(&to_mm)
306
- lines = Font.in_two collection.text, attributes
307
- lines = [[collection.text, Font.glyph_length(collection.text, attributes)]] if lines.map(&:first).map(&:length).min == 1
308
- width = lines.map(&:last).max
309
- height = lines.map { font_size }.inject { |total| total + line_height }
310
- if attributes["shield"]
311
- width += SHIELD_X * font_size
312
- height += SHIELD_Y * font_size
313
- end
314
- [*attributes["position"] || "over"].map.with_index do |position, position_index|
315
- dx = position =~ /right$/ ? 1 : position =~ /left$/ ? -1 : 0
316
- dy = position =~ /^below/ ? 1 : position =~ /^above/ ? -1 : 0
317
- f = dx * dy == 0 ? 1 : 0.707
318
- origin = [dx, dy].times(f * margin).plus(point)
319
-
320
- text_elements = lines.map.with_index do |(line, text_length), index|
321
- y = (lines.one? ? 0 : dy == 0 ? index - 0.5 : index + 0.5 * (dy - 1)) * line_height
322
- y += (CENTRELINE_FRACTION + 0.5 * dy) * font_size
323
- REXML::Element.new("text").tap do |text|
324
- text.add_attribute "transform", "translate(%s)" % POINT % origin
325
- text.add_attribute "text-anchor", dx > 0 ? "start" : dx < 0 ? "end" : "middle"
326
- text.add_attribute "textLength", VALUE % text_length
327
- text.add_attribute "y", VALUE % y
328
- text.add_text line
329
- end
330
- end
472
+ cumulative = distances.inject([0]) do |memo, distance|
473
+ memo << memo.last + distance
474
+ end
475
+ total = closed ? cumulative.pop : cumulative.last
331
476
 
332
- hull = [[dx, width], [dy, height]].map do |d, l|
333
- [d * f * margin + (d - 1) * 0.5 * l, d * f * margin + (d + 1) * 0.5 * l]
334
- end.inject(&:product).values_at(0,2,3,1).map do |corner|
335
- corner.plus point
336
- end
337
- next unless labelling_hull.surrounds? hull
338
-
339
- fence_count = fence_index.search(hull.transpose.map(&:minmax)).inject(Set[]) do |indices, fence|
340
- next indices if indices === fence.index
341
- fence.conflicts_with?(hull) ? indices << fence.index : indices
342
- end.size
343
- priority = [fence_count, position_index, feature_index]
344
- Label.new collection.layer_name, label_index, feature_index, priority, hull, attributes, text_elements
345
- end.compact.tap do |candidates|
346
- candidates.combination(2).each do |candidate1, candidate2|
347
- candidate1.conflicts << candidate2
348
- candidate2.conflicts << candidate1
349
- end
350
- end
351
- when GeoJSON::LineString
352
- closed = feature.coordinates.first == feature.coordinates.last
353
- pairs = closed ? :ring : :segments
354
- data = feature.coordinates.map(&to_mm)
355
-
356
- orientation = attributes["orientation"]
357
- max_turn = attributes["max-turn"] * Math::PI / 180
358
- min_radius = attributes["min-radius"]
359
- max_angle = attributes["max-angle"] * Math::PI / 180
360
- curved = attributes["curved"]
361
- sample = attributes["sample"]
362
- separation = attributes["separation-along"]
363
-
364
- text_length = case collection.text
365
- when REXML::Element then data.path_length
366
- when String then Font.glyph_length collection.text, attributes
367
- end
477
+ angles = vectors.map(&:normalised).send(pairs).map do |directions|
478
+ Math.atan2 directions.inject(&:cross), directions.inject(&:dot)
479
+ end
480
+ closed ? angles.rotate!(-1) : angles.unshift(0).push(0)
481
+
482
+ curvatures = segments.send(pairs).map do |(p0, p1), (_, p2)|
483
+ sides = [[p0, p1], [p1, p2], [p2, p0]].map(&:distance)
484
+ semiperimeter = 0.5 * sides.inject(&:+)
485
+ diffs = sides.map { |side| semiperimeter - side }
486
+ area_squared = [semiperimeter * diffs.inject(&:*), 0].max
487
+ 4 * Math::sqrt(area_squared) / sides.inject(&:*)
488
+ end
489
+ closed ? curvatures.rotate!(-1) : curvatures.unshift(0).push(0)
368
490
 
369
- points = data.segments.inject([]) do |memo, segment|
370
- distance = segment.distance
371
- case
372
- when REXML::Element === collection.text
373
- memo << segment[0]
374
- when curved && distance >= text_length
375
- memo << segment[0]
376
- else
377
- steps = (distance / sample).ceil
378
- memo += steps.times.map do |step|
379
- segment.along(step.to_f / steps)
380
- end
381
- end
382
- end
383
- points << data.last unless closed
491
+ dont_use = angles.zip(curvatures).map do |angle, curvature|
492
+ angle.abs > max_angle || min_radius * curvature > 1
493
+ end
384
494
 
385
- segments = points.send(pairs)
386
- vectors = segments.map(&:difference)
387
- distances = vectors.map(&:norm)
495
+ squared_angles = angles.map { |angle| angle * angle }
388
496
 
389
- cumulative = distances.inject([0]) do |memo, distance|
390
- memo << memo.last + distance
391
- end
392
- total = closed ? cumulative.pop : cumulative.last
497
+ barrier_overlaps = Hash.new do |overlaps, label_segment|
498
+ bounds = label_segment.transpose.map(&:minmax)
499
+ buffer = 0.5 * font_size
500
+ overlaps[label_segment] = barrier_segments.search(bounds, buffer: buffer).select do |barrier_segment|
501
+ barrier_segment.conflicts_with?(label_segment, buffer: buffer)
502
+ end.inject Set[] do |barriers, segment|
503
+ barriers.add segment.barrier
504
+ end
505
+ end
393
506
 
394
- angles = vectors.map(&:normalised).send(pairs).map do |directions|
395
- Math.atan2 directions.inject(&:cross), directions.inject(&:dot)
507
+ Enumerator.new do |yielder|
508
+ indices, distance, bad_indices, angle_integral = [0], 0, [], []
509
+ loop do
510
+ while distance < text_length
511
+ break true if closed ? indices.many? && indices.last == indices.first : indices.last == points.length - 1
512
+ unless indices.one?
513
+ bad_indices << dont_use[indices.last]
514
+ angle_integral << (angle_integral.last || 0) + angles[indices.last]
396
515
  end
397
- closed ? angles.rotate!(-1) : angles.unshift(0).push(0)
398
-
399
- curvatures = segments.send(pairs).map do |(p0, p1), (_, p2)|
400
- sides = [[p0, p1], [p1, p2], [p2, p0]].map(&:distance)
401
- semiperimeter = 0.5 * sides.inject(&:+)
402
- diffs = sides.map { |side| semiperimeter - side }
403
- area_squared = [semiperimeter * diffs.inject(&:*), 0].max
404
- 4 * Math::sqrt(area_squared) / sides.inject(&:*)
516
+ distance += distances[indices.last]
517
+ indices << (indices.last + 1) % points.length
518
+ end && break
519
+
520
+ while distance >= text_length
521
+ case
522
+ when indices.length == 2 && curved
523
+ when indices.length == 2 then yielder << indices.dup
524
+ when distance - distances[indices.first] >= text_length
525
+ when bad_indices.any?
526
+ when angle_integral.max - angle_integral.min > max_turn
527
+ else yielder << indices.dup
405
528
  end
406
- closed ? curvatures.rotate!(-1) : curvatures.unshift(0).push(0)
529
+ angle_integral.shift
530
+ bad_indices.shift
531
+ distance -= distances[indices.first]
532
+ indices.shift
533
+ break true if indices.first == (closed ? 0 : points.length - 1)
534
+ end && break
535
+ end if points.many?
536
+ end.map do |indices|
537
+ start, stop = cumulative.values_at(indices.first, indices.last)
538
+ along = (start + 0.5 * (stop - start) % total) % total
539
+ total_squared_curvature = squared_angles.values_at(*indices[1...-1]).inject(0, &:+)
540
+ baseline = points.values_at(*indices).crop(text_length)
541
+
542
+ barrier_count = baseline.segments.each.with_object Set[] do |segment, barriers|
543
+ barriers.merge barrier_overlaps[segment]
544
+ end.size
545
+ priority = [total_squared_curvature, (total - 2 * along).abs / total.to_f]
546
+
547
+ baseline.reverse! unless case orientation
548
+ when "uphill", "anticlockwise" then true
549
+ when "downhill", "clockwise" then false
550
+ else baseline.values_at(0, -1).map(&:first).inject(&:<=)
551
+ end
407
552
 
408
- dont_use = angles.zip(curvatures).map do |angle, curvature|
409
- angle.abs > max_angle || min_radius * curvature > 1
553
+ hull = GeoJSON::LineString.new(baseline).multi.buffer(0.5 * font_size, splits: false).coordinates.flatten(1).convex_hull
554
+ next unless labelling_hull.surrounds? hull
555
+
556
+ path_id = [@name, collection.layer_name, "path", label_index, feature_index, indices.first, indices.last].join ?.
557
+ path_element = REXML::Element.new("path")
558
+ path_element.add_attributes "id" => path_id, "d" => svg_path_data(baseline), "pathLength" => VALUE % text_length
559
+ text_element = REXML::Element.new("text")
560
+
561
+ case collection.text
562
+ when REXML::Element
563
+ fixed = true
564
+ text_element.add_element collection.text, "href" => "#%s" % path_id
565
+ when String
566
+ text_path = text_element.add_element "textPath", "href" => "#%s" % path_id, "textLength" => VALUE % text_length, "spacing" => "auto"
567
+ text_path.add_element("tspan", "dy" => VALUE % (CENTRELINE_FRACTION * font_size)).add_text(collection.text)
568
+ end
569
+ Label.new collection, label_index, feature_index, barrier_count, priority, [hull], attributes, [text_element, path_element], along, fixed
570
+ end.compact.reject do |candidate|
571
+ candidate.optional? && candidate.barriers?
572
+ end.then do |candidates|
573
+ neighbours = Hash.new do |hash, candidate|
574
+ hash[candidate] = Set[]
575
+ end
576
+ candidates.each.with_index do |candidate1, index1|
577
+ index2 = index1
578
+ loop do
579
+ index2 = (index2 + 1) % candidates.length
580
+ break if index2 == (closed ? index1 : 0)
581
+ candidate2 = candidates[index2]
582
+ offset = candidate2.along - candidate1.along
583
+ break unless offset % total < sample || (closed && -offset % total < sample)
584
+ neighbours[candidate2] << candidate1
585
+ neighbours[candidate1] << candidate2
586
+ end
587
+ end
588
+ removed = Set[]
589
+ candidates.sort.each.with_object Array[] do |candidate, sampled|
590
+ next if removed === candidate
591
+ removed.merge neighbours[candidate]
592
+ sampled << candidate
593
+ end.tap do |candidates|
594
+ next unless separation = attributes.dig("separation", "along")
595
+ separation += text_length
596
+ sorted = candidates.sort_by(&:along)
597
+ sorted.each.with_index do |candidate1, index1|
598
+ index2 = index1
599
+ loop do
600
+ index2 = (index2 + 1) % candidates.length
601
+ break if index2 == (closed ? index1 : 0)
602
+ candidate2 = sorted[index2]
603
+ offset = candidate2.along - candidate1.along
604
+ break unless offset % total < separation || (closed && -offset % total < separation)
605
+ candidate2.conflicts << candidate1
606
+ candidate1.conflicts << candidate2
410
607
  end
608
+ end
609
+ end
610
+ end
611
+ end
411
612
 
412
- squared_angles = angles.map { |angle| angle * angle }
413
-
414
- overlaps = Hash.new do |hash, segment|
415
- bounds = segment.transpose.map(&:minmax).map do |min, max|
416
- [min - 0.5 * font_size, max + 0.5 * font_size]
417
- end
418
- hash[segment] = fence_index.search(bounds).any? do |fence|
419
- fence.conflicts_with? segment, 0.5 * font_size
420
- end
613
+ def label_candidates(&debug)
614
+ label_features.flat_map.with_index do |collection, label_index|
615
+ log_update "compositing %s: feature %i of %i" % [@name, label_index + 1, label_features.length]
616
+ collection.each do |feature|
617
+ font_size = feature.properties["font-size"]
618
+ feature.properties.slice(*FONT_SCALED_ATTRIBUTES).each do |key, value|
619
+ feature.properties[key] = value.to_i * font_size * 0.01 if /^\d+%$/ === value
620
+ end
621
+ end.flat_map do |feature|
622
+ case feature
623
+ when GeoJSON::Point, GeoJSON::LineString
624
+ feature
625
+ when GeoJSON::Polygon
626
+ feature.coordinates.map do |ring|
627
+ GeoJSON::LineString.new ring, feature.properties
421
628
  end
629
+ end
630
+ end.tap do |features|
631
+ features.each.with_object("feature", &debug) if Config["debug"]
632
+ end.flat_map.with_index do |feature, feature_index|
633
+ case feature
634
+ when GeoJSON::Point
635
+ point_candidates(collection, label_index, feature_index, feature)
636
+ when GeoJSON::LineString
637
+ line_string_candidates(collection, label_index, feature_index, feature)
638
+ end
639
+ end.tap do |candidates|
640
+ candidates.reject!(&:point?) unless candidates.all?(&:point?)
641
+ end.sort.each.with_index do |candidate, index|
642
+ candidate.priority = index
643
+ end
644
+ end.tap do |candidates|
645
+ log_update "compositing %s: chosing label positions" % @name
422
646
 
423
- Enumerator.new do |yielder|
424
- indices, distance, bad_indices, angle_integral = [0], 0, [], []
425
- loop do
426
- while distance < text_length
427
- break true if closed ? indices.many? && indices.last == indices.first : indices.last == points.length - 1
428
- unless indices.one?
429
- bad_indices << dont_use[indices.last]
430
- angle_integral << (angle_integral.last || 0) + angles[indices.last]
431
- end
432
- distance += distances[indices.last]
433
- indices << (indices.last + 1) % points.length
434
- end && break
435
-
436
- while distance >= text_length
437
- case
438
- when indices.length == 2 && curved
439
- when indices.length == 2 then yielder << indices.dup
440
- when distance - distances[indices.first] >= text_length
441
- when bad_indices.any?
442
- when angle_integral.max - angle_integral.min > max_turn
443
- else yielder << indices.dup
444
- end
445
- angle_integral.shift
446
- bad_indices.shift
447
- distance -= distances[indices.first]
448
- indices.shift
449
- break true if indices.first == (closed ? 0 : points.length - 1)
450
- end && break
451
- end if points.many?
452
- end.map do |indices|
453
- start, stop = cumulative.values_at(*indices)
454
- along = (start + 0.5 * (stop - start) % total) % total
455
- total_squared_curvature = squared_angles.values_at(*indices[1...-1]).inject(0, &:+)
456
- baseline = points.values_at(*indices).crop(text_length)
457
-
458
- fence = baseline.segments.any? do |segment|
459
- overlaps[segment]
460
- end
461
- priority = [fence ? 1 : 0, total_squared_curvature, (total - 2 * along).abs / total.to_f]
647
+ if Config["debug"]
648
+ candidates.flat_map(&:hulls).each.with_object("candidate", &debug)
649
+ candidates.clear
650
+ end
462
651
 
463
- case
464
- when "uphill" == orientation
465
- when "downhill" == orientation then baseline.reverse!
466
- when baseline.values_at(0, -1).map(&:first).inject(&:<=)
467
- else baseline.reverse!
468
- end
652
+ Enumerator.new do |yielder|
653
+ # separation/self: minimum distance between a label and another label for the same feature
654
+ candidates.group_by do |label|
655
+ label.label_index
656
+ end.values.each do |group|
657
+ Label.overlaps(group) do |label|
658
+ label.dig("separation", "self")
659
+ end.inject(yielder, &:<<)
660
+ end
469
661
 
470
- hull = GeoJSON::LineString.new(baseline).multi.buffer(0.5 * font_size, splits: false).coordinates.flatten(1).convex_hull
471
- next unless labelling_hull.surrounds? hull
662
+ # separation/same: minimum distance between a label and another label with the same text
663
+ candidates.group_by do |label|
664
+ [label.layer_name, label.text]
665
+ end.values.each do |group|
666
+ Label.overlaps(group) do |label|
667
+ label.dig("separation", "same")
668
+ end.inject(yielder, &:<<)
669
+ end
472
670
 
473
- path_id = [@name, collection.layer_name, "path", label_index, feature_index, indices.first, indices.last].join ?.
474
- path_element = REXML::Element.new("path")
475
- path_element.add_attributes "id" => path_id, "d" => svg_path_data(baseline), "pathLength" => VALUE % text_length
476
- text_element = REXML::Element.new("text")
671
+ candidates.group_by do |candidate|
672
+ candidate.layer_name
673
+ end.each do |layer_name, group|
674
+ index = RTree.load(group, &:bounds)
675
+
676
+ # separation/other: minimum distance between a label and another label from the same layer
677
+ index.each do |bounds, label|
678
+ next unless buffer = label.dig("separation", "other")
679
+ index.search(bounds, buffer: buffer).with_object(label).select do |other, label|
680
+ Label.overlaps? label, other, buffer: buffer
681
+ end.inject(yielder, &:<<)
682
+ end
477
683
 
478
- case collection.text
479
- when REXML::Element
480
- text_element.add_element collection.text, "xlink:href" => "#%s" % path_id
481
- when String
482
- text_path = text_element.add_element "textPath", "xlink:href" => "#%s" % path_id, "textLength" => VALUE % text_length, "spacing" => "auto"
483
- text_path.add_element("tspan", "dy" => VALUE % (CENTRELINE_FRACTION * font_size)).add_text(collection.text)
484
- end
485
- Label.new collection.layer_name, label_index, feature_index, priority, hull, attributes, [text_element, path_element], along
486
- end.compact.map do |candidate|
487
- [candidate, []]
488
- end.to_h.tap do |matrix|
489
- matrix.keys.nearby_pairs(closed) do |pair|
490
- diff = pair.map(&:along).inject(&:-)
491
- 2 * (closed ? [diff % total, -diff % total].min : diff.abs) < sample
492
- end.each do |pair|
493
- matrix[pair[0]] << pair[1]
494
- matrix[pair[1]] << pair[0]
495
- end
496
- end.sort_by do |candidate, nearby|
497
- candidate.priority
498
- end.to_h.tap do |matrix|
499
- matrix.each do |candidate, nearby|
500
- nearby.each do |candidate|
501
- matrix.delete candidate
502
- end
503
- end
504
- end.keys.tap do |candidates|
505
- candidates.sort_by(&:along).inject do |(*candidates), candidate2|
506
- while candidates.any?
507
- break if (candidate2.along - candidates.first.along) % total < separation + text_length
508
- candidates.shift
509
- end
510
- candidates.each do |candidate1|
511
- candidate1.conflicts << candidate2
512
- candidate2.conflicts << candidate1
513
- end.push(candidate2)
514
- end if separation
684
+ # separation/<layer>: minimum distance between a label and any label from <layer>
685
+ candidates.each do |label|
686
+ next unless buffer = label.dig("separation", layer_name)
687
+ index.search(label.bounds, buffer: buffer).with_object(label).select do |other, label|
688
+ Label.overlaps? label, other, buffer: buffer
689
+ end.inject(yielder, &:<<)
515
690
  end
516
691
  end
517
- end.flatten.tap do |candidates|
518
- candidates.reject!(&:point?) unless candidates.all?(&:point?)
519
- end.sort_by(&:priority).each.with_index do |candidate, index|
520
- candidate.priority = index
521
- end
522
- end.flatten
523
-
524
- candidates.each do |candidate|
525
- debug_features << [candidate.hull, Set["debug", "candidate"]]
526
- end if debug
527
- return debug_features if %w[features candidates].include? debug
528
-
529
- candidates.map(&:hull).overlaps.map do |indices|
530
- candidates.values_at *indices
531
- end.each do |candidate1, candidate2|
532
- candidate1.conflicts << candidate2
533
- candidate2.conflicts << candidate1
534
- end
535
692
 
536
- candidates.group_by do |candidate|
537
- [candidate.label_index, candidate.attributes["separation"]]
538
- end.each do |(label_index, buffer), candidates|
539
- candidates.map(&:hull).overlaps(buffer).map do |indices|
540
- candidates.values_at *indices
541
- end.each do |candidate1, candidate2|
542
- candidate1.conflicts << candidate2
543
- candidate2.conflicts << candidate1
544
- end if buffer
693
+ # separation/dual: minimum distance between any two dual labels
694
+ candidates.select(&:dual).group_by do |label|
695
+ [label.layer_name, Set[label.text, label.dual]]
696
+ end.values.each do |group|
697
+ Label.overlaps(group) do |label|
698
+ label.dig("separation", "dual")
699
+ end.inject(yielder, &:<<)
700
+ end
701
+
702
+ # separation/all: minimum distance between a label and *any* other label
703
+ Label.overlaps(candidates) do |label|
704
+ # default of zero prevents any two labels overlapping
705
+ label.dig("separation", "all") || 0
706
+ end.reject do |label1, label2|
707
+ label1.coexists_with?(label2) ||
708
+ label2.coexists_with?(label1)
709
+ end.inject(yielder, &:<<)
710
+ end.each do |label1, label2|
711
+ label1.conflicts << label2
712
+ label2.conflicts << label1
713
+ end
545
714
  end
715
+ end
546
716
 
547
- candidates.group_by do |candidate|
548
- [candidate.layer_name, candidate.attributes["separation-all"]]
549
- end.each do |(layer_name, buffer), candidates|
550
- candidates.map(&:hull).overlaps(buffer).map do |indices|
551
- candidates.values_at *indices
552
- end.each do |candidate1, candidate2|
553
- candidate1.conflicts << candidate2
554
- candidate2.conflicts << candidate1
555
- end if buffer
717
+ def drawing_features
718
+ debug_features = []
719
+ candidates = label_candidates do |feature, category|
720
+ debug_features << [feature, Set["debug", category]]
556
721
  end
557
722
 
558
723
  conflicts = candidates.map do |candidate|
559
724
  [candidate, candidate.conflicts.dup]
560
725
  end.to_h
561
- labels, remaining, changed = Set.new, AVLTree.new, candidates
562
- grouped = candidates.to_set.classify(&:label_index)
563
- counts = Hash.new { |hash, label_index| hash[label_index] = 0 }
564
-
565
- loop do
566
- changed.each do |candidate|
567
- conflict_count = conflicts[candidate].count do |other|
568
- other.label_index != candidate.label_index
726
+
727
+ ordered, unlabeled = AVLTree.new, Hash.new(true)
728
+ remaining = candidates.to_set.classify(&:label_index)
729
+
730
+ Enumerator.produce do |label|
731
+ if label
732
+ removals = Set[label] | conflicts[label]
733
+ if first = unlabeled[label.label_index]
734
+ removals.merge remaining[label.label_index].select(&:barriers?)
735
+ unlabeled[label.label_index] = false
569
736
  end
570
- labelled = counts[candidate.label_index].zero? ? 0 : 1
571
- optional = candidate.optional? ? 1 : 0
572
- grid = candidate.layer_name == "grid" ? 0 : 1
573
- ordinal = [grid, optional, conflict_count, labelled, candidate.priority]
574
- next if candidate.ordinal == ordinal
575
- remaining.delete candidate
576
- candidate.ordinal = ordinal
577
- remaining.insert candidate
578
- end
579
- break unless label = remaining.first
580
- labels << label
581
- counts[label.label_index] += 1
582
- removals = Set[label] | conflicts[label]
583
- removals.each do |candidate|
584
- grouped[candidate.label_index].delete candidate
585
- remaining.delete candidate
586
- end
587
- changed = conflicts.values_at(*removals).inject(Set[], &:|).subtract(removals).each do |candidate|
588
- conflicts[candidate].subtract removals
589
- end
590
- changed.merge grouped[label.label_index] if counts[label.label_index] == 1
591
- end
592
737
 
593
- candidates.reject(&:optional?).group_by(&:label_index).select do |label_index, candidates|
594
- counts[label_index].zero?
595
- end.each do |label_index, candidates|
596
- label = candidates.min_by do |candidate|
597
- [(candidate.conflicts & labels).length, candidate.priority]
598
- end
599
- label.conflicts.intersection(labels).each do |other|
600
- next unless counts[other.label_index] > 1
601
- labels.delete other
602
- counts[other.label_index] -= 1
603
- end
604
- labels << label
605
- counts[label_index] += 1
606
- end if Config["allow-overlaps"]
738
+ removals.each do |candidate|
739
+ remaining[candidate.label_index].delete candidate
740
+ ordered.delete candidate
741
+ end
607
742
 
608
- grouped = candidates.group_by do |candidate|
609
- [candidate.label_index, candidate.feature_index]
610
- end
611
- 5.times do
612
- labels = labels.inject(labels.dup) do |labels, label|
613
- next labels unless label.point?
614
- labels.delete label
615
- labels << grouped[[label.label_index, label.feature_index]].min_by do |candidate|
616
- [(labels & candidate.conflicts - Set[label]).count, candidate.priority]
743
+ conflicts.values_at(*removals).inject(Set[], &:|).subtract(removals).each do |candidate|
744
+ conflicts[candidate].subtract removals
745
+ end.tap do |changed|
746
+ changed.merge remaining[label.label_index] if first
747
+ end
748
+ else
749
+ candidates
750
+ end.each do |candidate|
751
+ conflict_count = conflicts[candidate].each.with_object Set[] do |other, indices|
752
+ indices << other.label_index
753
+ end.delete(candidate.label_index).size
754
+ conflict_count += candidate.barrier_count
755
+
756
+ unsafe = candidate.conflicts.classify(&:label_index).any? do |label_index, conflicts|
757
+ next false unless unlabeled[label_index]
758
+ others = remaining[label_index].reject(&:optional?)
759
+ others.any? && others.all?(conflicts)
760
+ end
761
+
762
+ ordinal = [
763
+ candidate.fixed ? 0 : 1, # fixed grid-line labels
764
+ candidate.optional? ? 1 : 0, # non-optional candidates
765
+ unsafe ? 1 : 0, # candidates which don't prevent another feature being labeled altogether
766
+ unlabeled[candidate.label_index] ? 0 : 1, # candidates for unlabeled features
767
+ conflict_count, # candidates with fewer conflicts
768
+ candidate.priority # better quality candidates
769
+ ]
770
+
771
+ unless candidate.ordinal == ordinal
772
+ ordered.delete candidate
773
+ candidate.ordinal = ordinal
774
+ ordered.insert candidate
617
775
  end
618
776
  end
619
- end
620
777
 
621
- labels.map do |label|
622
- label.elements.map do |element|
623
- [element, label.categories]
778
+ ordered.first or raise StopIteration
779
+ end.to_set.tap do |labels|
780
+ grouped = candidates.group_by(&:indices)
781
+ 5.times do
782
+ labels.select(&:point?).each do |label|
783
+ labels.delete label
784
+ labels << grouped[label.indices].min_by do |candidate|
785
+ [(labels & candidate.conflicts - Set[label]).count, candidate.priority]
786
+ end
787
+ end
624
788
  end
625
- end.flatten(1).tap do |result|
626
- result.concat debug_features if debug
789
+ end.flat_map do |label|
790
+ label.elements.map.with_object(label["categories"]).entries
791
+ end.tap do |result|
792
+ next unless debug_features.any?
793
+ @params = DEBUG_PARAMS.deep_merge @params
794
+ result.concat debug_features
627
795
  end
628
796
  end
629
797
  end